5
0
Fork
You've already forked hark
0

feat(mls): SPEC-013 — agents in encrypted private channels (IMPL-013) #9

Merged
anuna-02 merged 7 commits from feat/impl-013-mls into main 2026年06月10日 15:02:31 +02:00

Implements the hark side of SPEC-013 (Tier-1 gate CLEARED 2026年06月10日), planned and executed via hence (plans/impl-013-mls.spl, 11/11 tasks done).

What

New src/mls/ subsystem (provider, pins, keypackages, group, validation, removal, safety, session) wired into the chat transport, local API, and a new hark safety-number command:

  • Identity binding (REQ-007/ADR-002): MLS leaf signer is the wire Ed25519 key, byte-for-byte.
  • Durable state (ADR-004, REQ-009, NFR-004): 0600 atomic snapshots under identity_dir; deletes verified absent on disk (condition I); version bump = re-join.
  • Pins (REQ-011/019, condition A-t): TOFU pins that flag-never-rotate; idkey/rekey ceremonies under dedicated DS labels; cross-protocol no-collision property test.
  • KeyPackages (REQ-002/013/015/022): durable consumed-ref ledger; failed Welcome validation can never burn an init key (provider rollback); last-resort lifetime enforced by the primitive.
  • Group lifecycle (REQ-001/003/004/008/012/016, condition K-2): genesis GroupContext extension with creation-time capability guard; election over MLS leaves; full-tree pin-checked Welcome validation.
  • Merge gate (REQ-005/006/017/018): rejects the spike-proven R3-07 credential rebind; fail-closed proposal allowlist (genesis immutable); removal evidence verified by every validator; sender-authenticated :from; drop-but-count fork signal.
  • Removal (REQ-014, condition K-1): exact-epoch evidence; race-then-retry path tested; creator fallback; third-party evidence rejected.
  • Safety numbers (REQ-021/024): pinned byte-exact encoding + cross-stack vector; stable across ratchet commits; offline CLI surface.
  • Mode pin (REQ-023, NFR-002): derived from the admission path (cap ⇒ encrypted), persisted; roomcfg :enc false downgrade refused fail-closed; no plaintext fallback anywhere.

Verification

  • 210 lib tests + 2 integration tests pass (52 new); clippy clean on the new code.
  • REQ→TEST trace: docs/decisions/IMPL-013-trace.md.
  • Pre-existing live-router suites (router_integration, e2e_mvp, agent_workflow_cli) fail identically on clean main — unrelated.

Affected repos (changes staged locally, not in this PR)

  • cbcl-chat: verified bye fanned room-wide as removal evidence before leave; creator handle recorded at claim (59/59 eunit tests pass).
  • cbcl-mls-wasm: Identity::from_wire_key, genesis capability + K-2 guard, genesis()/member_handles() (8/8 tests, wasm32 clean).
  • cbcl-bus web: election from MLS leaves with graceful fallback. Known seam: existing web identities are WebCrypto non-extractable — wire-key binding needs an identity-regeneration path (TODO in app.js); browser wasm artifact needs re-vendoring.

🤖 Generated with Claude Code

Implements the hark side of SPEC-013 (Tier-1 gate CLEARED 2026年06月10日), planned and executed via hence (`plans/impl-013-mls.spl`, 11/11 tasks done). ## What New `src/mls/` subsystem (provider, pins, keypackages, group, validation, removal, safety, session) wired into the chat transport, local API, and a new `hark safety-number` command: - **Identity binding (REQ-007/ADR-002):** MLS leaf signer is the wire Ed25519 key, byte-for-byte. - **Durable state (ADR-004, REQ-009, NFR-004):** 0600 atomic snapshots under `identity_dir`; deletes verified absent on disk (**condition I**); version bump = re-join. - **Pins (REQ-011/019, condition A-t):** TOFU pins that flag-never-rotate; `idkey`/`rekey` ceremonies under dedicated DS labels; cross-protocol no-collision property test. - **KeyPackages (REQ-002/013/015/022):** durable consumed-ref ledger; failed Welcome validation can never burn an init key (provider rollback); last-resort lifetime enforced by the primitive. - **Group lifecycle (REQ-001/003/004/008/012/016, condition K-2):** genesis GroupContext extension with creation-time capability guard; election over MLS leaves; full-tree pin-checked Welcome validation. - **Merge gate (REQ-005/006/017/018):** rejects the spike-proven R3-07 credential rebind; fail-closed proposal allowlist (genesis immutable); removal evidence verified by every validator; sender-authenticated `:from`; drop-but-count fork signal. - **Removal (REQ-014, condition K-1):** exact-epoch evidence; race-then-retry path tested; creator fallback; third-party evidence rejected. - **Safety numbers (REQ-021/024):** pinned byte-exact encoding + cross-stack vector; stable across ratchet commits; offline CLI surface. - **Mode pin (REQ-023, NFR-002):** derived from the admission path (cap ⇒ encrypted), persisted; `roomcfg :enc false` downgrade refused fail-closed; no plaintext fallback anywhere. ## Verification - 210 lib tests + 2 integration tests pass (52 new); clippy clean on the new code. - REQ→TEST trace: `docs/decisions/IMPL-013-trace.md`. - Pre-existing live-router suites (`router_integration`, `e2e_mvp`, `agent_workflow_cli`) fail identically on clean `main` — unrelated. ## Affected repos (changes staged locally, not in this PR) - **cbcl-chat:** verified `bye` fanned room-wide as removal evidence before leave; creator handle recorded at claim (59/59 eunit tests pass). - **cbcl-mls-wasm:** `Identity::from_wire_key`, genesis capability + K-2 guard, `genesis()`/`member_handles()` (8/8 tests, wasm32 clean). - **cbcl-bus web:** election from MLS leaves with graceful fallback. Known seam: existing web identities are WebCrypto non-extractable — wire-key binding needs an identity-regeneration path (TODO in `app.js`); browser wasm artifact needs re-vendoring. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
New src/mls/ subsystem wired into the chat transport, local API, and CLI:
- identity binding: the MLS leaf signer IS the wire Ed25519 key (REQ-007/ADR-002)
- durable OpenMLS provider under identity_dir with on-disk delete fidelity,
 0600 atomic snapshots, version-bump-is-a-rejoin (ADR-004, REQ-009, NFR-004,
 condition I)
- handle→wire-key pin store with idkey/rekey ceremonies under their own DS
 labels, plus the OQ-001 no-collision property test (REQ-011, REQ-019,
 condition A-t)
- KeyPackage lifecycle: durable consumed-ref ledger, delete-after-successful-
 join ordering via provider rollback, primitive-enforced last-resort lifetime
 (REQ-002, REQ-013, REQ-015, REQ-022)
- group lifecycle: genesis GroupContext extension with creation-time
 capability guard (condition K-2), leaf-derived owner election, REQ-008 adder
 verification, full-tree pin-checked Welcome validation (REQ-001/003/004/012/016)
- inbound merge gate: credential immutability (rejects the spike-proven R3-07
 rebind), fail-closed proposal allowlist, merge-time removal evidence,
 sender-authenticated :from, drop-but-count fork signal (REQ-005/006/017/018)
- authenticated removal: exact-epoch signed evidence with the race-retry path
 (REQ-014, condition K-1)
- safety numbers: pinned byte-exact identity-number encoding + epoch state
 hash, surfaced via `hark safety-number` (REQ-021, REQ-024)
- session wiring: encryption-mode pin from the admission path, downgrade
 fail-closed, no plaintext fallback (REQ-023, NFR-002)
52 new tests (unit + tests/mls_private_channel.rs); REQ→TEST trace in
docs/decisions/IMPL-013-trace.md; plan + execution journal in
plans/impl-013-mls.spl (hence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the no-trigger gap: MlsSession::create_group_as_creator existed and was
tested but no CLI/daemon path reached it, so a hark agent could join an
encrypted channel but never own/create the group over the live wire. The new
--mls-create flag (plumbed init -> CreateAgentRequest -> create_chat_agent ->
post-join, REQ-016 operator intent) calls it after the join ack when the
channel is pinned encrypted, before publishing KeyPackages, so the agent is the
sole member / elected owner and adds present members on presence.
Verified live against the cbcl-bus hub: the agent bootstraps the group (genesis
present, epoch 0, creator recorded, `hark safety-number` reports it).
Also documents the live playtest findings in docs/decisions/IMPL-013-trace.md,
including the one gap that blocks a 2-member group over the live wire: the
`idkey` pin assertion (REQ-019) is fanned once at join to then-connected members
and never replayed to late joiners, so the creator can't pin a peer to satisfy
REQ-008's adder check. Fix direction recorded; it's a REQ-019/REQ-011
distribution refinement independent of this trigger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Planning doc for SPEC-016 REQ-007 pairing. Key findings: no Rust SPAKE2 exists
(only an LFE module in cbcl-bus on the cbcl_ristretto NIF), so hark must build
an interoperable Rust SPAKE2 or take a shared NIF; the REQ-023 enc-mode pin
chain already works unchanged (pairing just feeds the record's cbcl-chat-invite
cap into CreateAgentRequest.cap, pinning enc=true via cap presence); `hark pair`
is a new Command variant mirroring init_command.
Records the ADR-006 caveat (pairing anchors capability + name, NOT MLS
identity), the three residuals a reviewer must accept, and a phased split:
Phase A (CLI shape, BIP39 parsing, record→request plumbing, cbcl-bus minting)
is safe to build now; Phase B (the SPAKE2 handshake) is Tier-1 and blocked on
condition J (the cbcl_ristretto point-validation audit) until a cross-model
review + human crypto sign-off clear it, per PROTO-001.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes for the live-playtest gap where a 2-member group never formed (the
owner could never pin a peer to satisfy the REQ-008 adder check):
1. **Address idkey to the room, not the handle.** The hub fans a frame only to
 members of the room it is addressed to; hark addressed `(idkey @handle ...)`,
 so the hub rejected it `not-a-member` and never fanned it — no peer ever
 received an idkey (every pin store held only `self`). Now `(idkey @room
 :handle @h ...)`, matching the contract `deliver`/`welcome` already use. The
 signed context (handle, key, room, nonce) is unchanged.
2. **Re-broadcast idkey when a new member appears in presence.** The hub fans an
 idkey once at join to then-connected members; a peer that joined earlier (or
 the owner joining later) never receives it. On observing a new handle in
 `presence` a member re-announces its idkey, and pinning a newly-seen member
 (on_idkey) now immediately emits the owner's keyget — so the Add is gated on
 having the pin and never races ahead of it, regardless of frame arrival order.
New test `owner_joining_after_peer_still_adds_via_idkey_rebroadcast` drives the
exact late-joiner ordering from the playtest and asserts the 2-member group
forms with agreeing safety numbers. 211 lib tests pass.
NOTE: verified at the session/frame level (unit). The live two-hark-agent
exchange over the running hub is NOT yet confirmed — see IMPL-013-trace.md;
diagnosing the live path is blocked on daemon log observability in the sandbox.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes found by tracing the live hub flow, on top of the idkey-rebroadcast
work — together they let two hark agents form a shared MLS group and exchange
encrypted messages over the real hub:
1. **idkey must carry `:from`.** The hub rejects any room-fanned frame without
 a `:from` attribution (`error "missing-from"`) — so the idkey, which carried
 the asserter in `:handle`, was dropped and never reached peers (no agent
 ever pinned another). The asserter IS the sender, so the frame now uses
 `:from @handle`, matching the `deliver`/`welcome` contract; on_idkey reads
 the asserter from `:from`. The signed context is unchanged.
2. **Welcome committer check must use the pre-add roster.** REQ-012(b) elected
 the owner over the delivered (post-add) tree, so when the creator bootstraps
 the first member the new 2-member tree's elected owner could be the
 newcomer, not the committing creator — the joiner then rejected its own
 Welcome ("elected owner is @other"). The committer's authority comes from
 owning the group BEFORE the Add, so we now elect over the tree minus the
 joiner: the creator authorises the bootstrap Add, and the current elected
 owner authorises steady-state adds (REQ-016).
Verified LIVE against the cbcl-bus hub: `@finch --mls-create` and `@sparrow`
form a group (identical REQ-024 identity safety number on both), and a `hark
reply` from @finch is delivered as an MLS `deliver` frame that @sparrow
decrypts back to the exact plaintext. 211 lib tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
High-effort code review surfaced four real defects:
1. **Durable consumed-ref ledger was dead code (REQ-013).** join_from_welcome
 recorded refs via welcome_refs_of_staged(), which was hardcoded to return
 Vec::new() — so the single-use ledger stayed empty forever and its
 replay-rejection could never fire. Capture the refs before staging and
 mark them consumed on a successful join; drop the dead helper. Regression
 test asserts the ledger persists and a replayed Welcome is rejected.
2. **TOFU genesis was promoted to Authoritative on restart.** MlsSession::open
 reloaded every group as Authoritative, erasing the TofuRequiresSafetyNumber
 grade across a daemon restart (HP-3) — defeating the safety-number control
 the whole REQ-016/ADR-006 first-contact mitigation leans on. Persist the
 grade (tofu_pending) and restore it faithfully.
3. **Duplicate-member Add.** add_member didn't check the target wasn't already
 a member, so an unsolicited/replayed keypkg for an existing handle (fresh
 one-time ref) would seat a second leaf — corrupting the election, the
 REQ-021 safety number (a false fork vs. web peers), and seating a duplicate
 decryptor. Reject an Add whose handle is already a member. Regression test.
4. **persist_meta non-atomic.** The resume-state meta file used a plain
 fs::write; a crash mid-write truncates it and silently forces a re-join
 (REQ-009). Use the temp+fsync+rename pattern the provider/pins/ledger use.
Documented residuals (not fixed; fail-closed or accepted): the added_by adder
is not yet in the removal-authority allowlist (over-restrictive vs REQ-014(b),
needs added_by tracking); genesis-capability is not re-checked on Update leaves
(openmls enforces at the protocol layer); unbounded non-owner self-update epoch
churn (K-1 availability residual). 213 lib + 2 integration tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
Labels
Clear labels
No items
No labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
anuna/hark!9
Reference in a new issue
anuna/hark
No description provided.
Delete branch "feat/impl-013-mls"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?