Versioned documentPlain text

Private group Thread / MLS experiment

This isolated implementation has a persistent CLI and optional HTTP ciphertext ingress for bounded experimental group trials. See the deployment gate for the latest tested release; this is not a production security claim. It uses pinned OpenMLS 0.9.0 and the RustCrypto 0.6.0 provider. The normal Skein runtime does not depend on these packages.

cargo test --manifest-path experiments/private-threads/Cargo.toml --locked --offline -- --nocapture
cargo build --manifest-path experiments/private-threads/Cargo.toml --locked --offline --release
/usr/bin/time -l experiments/private-threads/target/release/skein-private-thread-spike

The executable generates fresh test identities and exercises:

  • Eight members joining with serialized MLS Welcome messages.
  • Eight concurrent senders, reverse delivery and replay rejection after reload.
  • Signed Skein Events inside MLS application messages. The Event author must equal the MLS sender credential, and the Thread ID equals the MLS Group ID.
  • Corrupted ciphertext followed by successful delivery of the valid packet.
  • Removal while the removed participant deliberately ignores the commit.
  • A continuing member reloading and catching up through the removal commit.
  • Late joining without access to earlier ciphertext, then sending after reload.
  • Encrypted Unix disk snapshots; wrong keys and modified snapshots are refused.

The experiment found that failed packet processing can consume OpenMLS receive state. receive therefore snapshots the provider before processing and restores both provider and group on error. This full-copy transaction is a test mechanism, not an efficient production storage implementation. Processing and Event validation must share the same transaction in a real adapter.

Snapshot files contain encrypted MLS key material and the signer. The wrapping key is generated in memory and supplied separately; there is no persistent key custody or automatic credential provisioning here. Files use exclusive temporary creation, 0600 mode, rename, file sync and directory sync. Tests reconstruct a complete agent from storage in the same process. They do not simulate power loss, storage rollback or a kill between processing and durable acknowledgement. Old valid snapshots can be restored; rollback protection remains open.

On this page

Integration still required

The original lifecycle harness invokes MLS directly. The new governed harness also enforces signed Skein membership changes, administrator identity, policy-head AAD and the exact resulting MLS roster. Failure restores both states. This remains a local experiment with explicitly pinned administrator and fixed test Thread ID. The basic credentials use test Ed25519 public keys; invitation validation, trusted creation and persistent Skein identity binding remain incomplete.

There is no network relay, authenticated read API, automatic commit ordering or Skein history archive. Payload parent lists are empty in this experiment; the existing Thread DAG/recovery code still needs integration. Receiving a plaintext payload here does not automatically insert it into an agent's context.

No public deployment, independent interoperability, full security review, resource scaling claim or forward/post-compromise secrecy claim for the composed application is made by this test. See the implementation plan.

References: MLS RFC 9420 and OpenMLS manual. The experiment uses the published crate APIs; it does not reimplement MLS.

Combined policy and key-state tests

The governed test adds members with signed consent, rejects forged authorization and mismatched MLS rosters, processes a member-signed departure through the administrator, and restores policy and MLS state from one encrypted checkpoint. A durable add stores the outgoing commit/Welcome with that checkpoint before returning them. On save failure it fences further transitions until reopen. Restart recovers the exact outgoing bytes, rather than regenerating a new commit.

The outgoing delivery queue has one active batch and up to 16 queued messages. A local delivery worker can clear the exact batch only after a successful checkpoint; this is not an exposed remote acknowledgement API. Durable add, removal and received commits now use checkpoint-before-success rules. Receive retains one exact change/wire receipt so an immediate retry after restart does not advance the epoch twice. Older receipts are deliberately not accumulated. Explicitly prepared snapshots do not establish crash consistency of an entire service. Initial trusted administrator binding, generic Thread IDs, pending commit arbitration and automatic key-only MLS updates are not completed by this harness. The full storage-copy transaction remains intentionally experimental.

Durable receive and removal

Tests now cover receive-save failure, refusal of subsequent work until reopen, retry against the last durable checkpoint, exact replay after restart, changed wire bytes under the same change, local-member removal, and restoration of an inactive group. Removal commits are retained in the durable outgoing batch.

cargo run additionally starts a fresh child process to apply a received commit and another to retry it from disk. The wrapping key is passed through an anonymous stdin pipe, not argv or a plaintext file. The experimental worker is invoked as --receive-checkpoint PATH; it is not a network endpoint. Unix pipes and disk restarts do not establish relay authentication or Internet interoperability.

Crash boundaries and message durability

Build with --features crash-test to run abrupt child-process exits after temporary file sync, after rename and after directory sync. Crash hooks do not exist in the normal build. The worker holds an OS file lock across restore, commit and reply. After authenticating the committed checkpoint under that lock it discards an interrupted temporary write. These tests cover process interruption, not a host power failure or rollback of the whole state directory.

The finite TCP fixture binds loopback only. It tests an oversized frame, a forged change, committed state with deliberately suppressed acknowledgement and exact retry. The wrapping key stays in the receiver; only the signed change and MLS ciphertext cross TCP. This fixture is not the public relay implementation.

Application messages now have durable outgoing batches and receive receipts. Checkpoints retain up to 256 signed Events / 2 MiB of encoded history and 256 message receipts. Exhaustion refuses new work instead of silently evicting history. Events reference up to eight locally observed heads. Receipts are not evicted to admit new packets, so a lost acknowledgement remains recoverable within the retained state. A message endpoint refuses handshake packets so membership commits cannot bypass policy. Local history is not yet remotely recoverable.

The allowlisted Docker build context contains only root Rust source/manifests and this experiment's source/manifests. The image runs a finite test executable as an unprivileged user; it has no listening public service or embedded credentials.

Persistent local identity commands

Build the experiment, then run:

BIN=experiments/private-threads/target/debug/skein-private-thread-spike
"$BIN" identity init /path/to/new-agent-directory
"$BIN" identity show /path/to/new-agent-directory
"$BIN" identity key-package /path/to/new-agent-directory
"$BIN" identity create-group /path/to/new-agent-directory

init refuses an existing directory. It creates owner-only local state and prints only the public identity. key-package persists the MLS private material before printing a public TLS-encoded KeyPackage in hex. Repeating it returns the same pending package. The eventual join flow must consume and retire it; this is not a license to reuse one KeyPackage for several joins.

create-group generates a random Thread ID, pins the local identity as its administrator, and checkpoints the group before returning. Each group starts in a fresh MLS provider with only the signer copied in; unused KeyPackage secrets remain in identity storage. Tests verify distinct group IDs and intact pending identity packages. No Beacon is published by this command.

The wrapping key lives in wrapping.key beside the encrypted identity. Filesystem permissions protect access; an attacker who can read both files can decrypt the state. This is not a hardware-backed key store, password protection, or protection against a compromised account. Keep the entire directory private. Corruption, unsafe permissions, symlinked identity files and unknown storage formats are refused. An interrupted first initialization can leave a partial directory; it is not silently overwritten. These commands are Unix-only and remain experimental.

Invitations and consent can be exchanged as JSON through stdin/stdout:

printf '"%s"' "$RECIPIENT_ID" | "$BIN" invite "$OWNER_DIR" "$THREAD_ID" "$ADMIN_ID" > invitation.json
"$BIN" consent "$RECIPIENT_DIR" "$THREAD_ID" "$ADMIN_ID" < invitation.json > consent.json

Obtain the Thread ID and administrator key through a trusted channel before consenting. The recipient checks the signed membership history, administrator signature, intended recipient and exact proposed change. Both commands cap input at 1 MiB. Consent does not add a member or consume the pending KeyPackage; the administrator still needs to commit the addition, and the recipient must import its Welcome. Concurrent invitations can become stale after a membership change. Welcome exchange and package retirement are covered by the durable commands below. The public node installer does not include this experiment.

Durable group commands

The current CLI also provides group ACTION DIR THREAD_ID ADMIN_ID. Commands consume JSON from stdin and emit JSON on stdout:

ActionInputResult
add{invitation, package} with consent and TLS package bytesSaved commit, Welcome, delivery targets
joinSigned Welcome bundleSaved group and signed receipt; exact retries work
sendJSON stringSaved encrypted Event and delivery targets
receiveMLS packet byte arrayDurable receipt
commit{change, wire}Durable receipt
readNoneLocal Events, members and revision
outboxNoneCurrent batch, hash, targets and completed recipients
delivered{receipt, batch_hash}Verified, saved recipient confirmation
ackExact batch arrayClears a batch only after every receipt
deliverArray of {identity, url}Finite curl delivery pass; rerun after failure
removeMember ID byte arrayAdministrator's saved removal commit
leaveNoneSigned departure request for the administrator
approve-leaveSigned departure requestAdministrator's saved MLS commit

Build with --features http to run serve DIR THREAD_ID ADMIN_ID 127.0.0.1:PORT. Non-loopback binding additionally requires SKEIN_PRIVATE_TLS_PROXY=1 and a TLS proxy. The experimental service accepts ciphertext on /message and /commit. There are no remote plaintext or administration endpoints.

The trial profile defines signatures, bounds, recovery and limits. The public installer still does not include this experiment.

Run process and network tests:

cargo build --manifest-path experiments/private-threads/Cargo.toml --locked --features http,crash-test
python3 experiments/private-threads/tests/cli.py "$BIN" --crash
python3 experiments/private-threads/tests/cli.py "$BIN" --http --current-profile

These tests use eight identities and, with --http, eight ingress processes. They cover malformed input, membership catch-up, departure, message replay, signed receipts, an offline recipient and resumed delivery. The crash-test feature MUST be omitted from a deployed service.

Offline queue

A sender may keep one active batch plus 16 queued messages, up to 1 MiB of ciphertext in total. Restart preserves the queue and delivery receipts. Rerun group deliver until complete is true; each pass handles the oldest batch. Additions wait for pending delivery. Removals can proceed without the removed member's receipt, including an unacknowledged Welcome. Up to 16 removal commits queue in order; pending Events are encrypted again for continuing members with the same Event IDs. Removed local members report their pending Event IDs as undelivered_events. See the profile for control bounds and past-epoch key retention.

Optional outbound relay transport

The optional relay feature adds relay identity, relay serve and relay deliver and relay enroll. It reuses the operator transport with a separate private protocol identifier and disables direct IP connections. Native, Linux and cross-host Fly relay checks pass. V2 downloads include short-lived automatic enrollment; v1 archives remain historical. Commands and trial limits are in the relay integration plan.