Implements V1 of the native cbcl-chat transport for hark — cbcl-chat SPEC-003 / IMPL-003. A hark agent joins an cbcl-chat channel directly over /chat/v1 as an ordinary Ed25519-signed member, no cbcl-router required.
What's here (all tested)
| Module |
Role |
Tests |
selector.rs |
RendezvousHash answerer-selection (argmin H(ask_id ‖ handle)), Selector trait |
8 |
chat_frame.rs |
signed-frame codec (len ‖ payload ‖ 64-byte sig) + FrameSigner seam |
5 |
identity.rs |
Ed25519 chat identity via ed25519-dalek |
3 |
chat.rs |
/chat/v1 transport: connect, signed hello, join, recv/send loop |
2 + 1 live |
124/124 lib tests pass.
Live-verified
The ignored tests/chat_live.rs was run against a real cbcl-chat hub: a hark agent joins #general, the hub accepts the ed25519-dalek signature (libsodium verify-sender), and returns roomcfg + presence — the agent appears in the roster. The Ed25519/frame interop (the risky part) works end to end. This is SPEC-003 REQ-001 (signed membership).
Design notes
- Selector = RendezvousHash, not VRF. No audited RFC-9381 ed25519 Rust crate exists (IMPL-003 OQ-003), so V1 uses RendezvousHash behind the pluggable
Selector seam; the VRF is the gated upgrade.
- Only crypto is standard Ed25519 (
ed25519-dalek), mirroring the cbcl-chat browser frame format. The FrameSigner trait isolates it at one seam.
- Reuses the transport-agnostic
AgentStore, the DialectCache, and the cbcl_parser pipeline. chat.rs is an additive sibling to router.rs — the router transport is untouched, and a daemon can hold both connections at once.
Not in this PR (next)
Capability filter (REQ-002), the claim round + selection wiring (REQ-005/007), multi-channel join on one connection, the chat CLI subcommand + ChatConfig, and the two-agent ask→reply demo. The VRF selector stays deferred (Tier-1 gated).
🤖 Generated with Claude Code
Implements **V1 of the native cbcl-chat transport** for hark — `cbcl-chat` SPEC-003 / IMPL-003. A hark agent joins an `cbcl-chat` channel directly over `/chat/v1` as an ordinary Ed25519-signed member, no `cbcl-router` required.
## What's here (all tested)
| Module | Role | Tests |
|---|---|---|
| `selector.rs` | RendezvousHash answerer-selection (`argmin H(ask_id ‖ handle)`), `Selector` trait | 8 |
| `chat_frame.rs` | signed-frame codec (`len ‖ payload ‖ 64-byte sig`) + `FrameSigner` seam | 5 |
| `identity.rs` | Ed25519 chat identity via `ed25519-dalek` | 3 |
| `chat.rs` | `/chat/v1` transport: connect, signed `hello`, join, recv/send loop | 2 + 1 live |
124/124 lib tests pass.
## Live-verified
The ignored `tests/chat_live.rs` was run against a real cbcl-chat hub: a hark agent joins `#general`, the hub **accepts the `ed25519-dalek` signature** (libsodium `verify-sender`), and returns `roomcfg` + `presence` — the agent appears in the roster. The Ed25519/frame interop (the risky part) works end to end. This is SPEC-003 **REQ-001 (signed membership)**.
## Design notes
- **Selector = RendezvousHash, not VRF.** No audited RFC-9381 ed25519 Rust crate exists (IMPL-003 OQ-003), so V1 uses RendezvousHash behind the pluggable `Selector` seam; the VRF is the gated upgrade.
- **Only crypto is standard Ed25519** (`ed25519-dalek`), mirroring the cbcl-chat browser frame format. The `FrameSigner` trait isolates it at one seam.
- **Reuses** the transport-agnostic `AgentStore`, the `DialectCache`, and the `cbcl_parser` pipeline. `chat.rs` is an additive sibling to `router.rs` — the router transport is untouched, and a daemon can hold both connections at once.
## Not in this PR (next)
Capability filter (REQ-002), the claim round + selection wiring (REQ-005/007), multi-channel join on one connection, the `chat` CLI subcommand + `ChatConfig`, and the two-agent ask→reply demo. The VRF selector stays deferred (Tier-1 gated).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The pure answerer-selection core for the cbcl-chat transport: argmin
H(ask_id ‖ handle) over the contenders, winner first, ranking drives the
liveness fallback. No keys, no proofs, no I/O — the VRF selector (gated)
will implement the same Selector trait later. 8 unit tests (determinism,
order-independence, dedup, argmin, empty/single).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
len(4,BE) ‖ payload ‖ 64-byte sig, byte-identical to the browser. Framing
is ungated; the signature is isolated behind the FrameSigner trait (Phase-2
ed25519-dalek plugs in here). NullSigner stub for now. 5 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persisted RFC 8032 Ed25519 keypair via ed25519-dalek — the agent's
on-wire identity, TOFU-enrolled by the hub. Implements FrameSigner, so it
plugs into the frame codec at the one crypto seam. Same primitive as the
browser (WebCrypto) + hub (libsodium); interoperable. 3 tests (sign/verify,
pubkey b64, persist/reload). Standard crypto; VRF still deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_chat_agent connects to /chat/v1, sends an Ed25519-signed hello to
join a channel, and bridges the socket to the daemon's inbound/outbound
queues (mirrors router.rs). Outbound = validated CBCL text, signed +
length-framed; inbound = payload extracted + enqueued (hub vouches for the
signer, like the browser).
LIVE-VERIFIED against a running hub: a hark agent joins #general and the
hub accepts the dalek signature (libsodium verify) — roomcfg + presence
returned, agent appears in presence. The ed25519/canonical-bytes interop
(the risky part) works. Ignored integration test tests/chat_live.rs.
Next: capability filter + claim round + RendezvousHash selection + recv/
reply CLI wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hark now grows a second transport (src/chat.rs) joining cbcl-chat /chat/v1
directly as a signed member (SPEC-003 / IMPL-003). Marked WIP — connect +
signed hello + join is live-verified; capability filter, selection, and the
chat CLI are not wired yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three findings from review of feat/spec-003-chat-transport:
- chat_frame: decode_payload now requires the frame to be exactly
len(4) ‖ payload(len) ‖ sig(64). Previously it accepted any frame
>= 4 + len, silently ignoring a missing/short signature and trailing
junk. Truncated, over-long, or wrong-format frames are now rejected.
- identity: load_or_create only generates a new key on ErrorKind::NotFound.
A transient read error (EACCES/EIO) no longer silently rotates the
agent's identity. The seed is created atomically with create_new + mode
0o600 (no post-write chmod race), and read/write errors propagate.
- chat: create_chat_agent now blocks on the hub's join verdict before
returning Ok. It awaits the acknowledging roomcfg/presence frame, or
surfaces a rejection (bad-signature / no-such-channel / forbidden-room,
which the hub sends as `(error @room "slug")` with the socket left open)
as ChatError::JoinRejected, with JoinTimeout as a backstop. Frames are
classified by parsed performative, not substring. Rejection happens
before any store entry exists, so there is nothing to clean up.
Live-verified against a running hub: accept path returns Ok (+ presence
flows), and entering a non-existent channel returns JoinRejected("no-such-channel").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chat transport was a tested library module with no way to invoke it.
This integrates it through the existing subcommands, selecting router vs
chat from the configured hub URL's path — no new subcommands.
- config: ws_url path decides the transport (`/chat/v1` -> chat, else
router). New [chat] section (channel default, per-handle identity_dir);
validate_ws_url is shared, validate_chat applies defaults, and chat
needs no auth token (it authenticates per frame by signature). Env
overrides CBCL_CHAT_CHANNEL / CBCL_CHAT_IDENTITY_DIR.
- daemon create_agent branches on the transport. The chat branch loads or
creates a per-handle Ed25519 key at <identity_dir>/<handle>.key, joins
the channel as a signed member, and skips the @router auto-install step.
recv/reply/error/progress/close are unchanged — they already operate on
the local handle and are transport-agnostic.
- Each agent process is its own member: a per-process wire handle (--handle
@name, required for chat) with its own key, enrolled independently by the
hub (TOFU, handle -> key). The store now records the chat @handle as the
agent's wire id so `status` reports the real identity.
- Private channels: create_chat_agent takes an optional cap, emitted as
`:cap "<token>"` in the hello (--cap on init). Public channels omit it.
- Dialect subcommands return not_supported_on_chat_hub (no @router; SPEC-005
per-channel dialects are a separate task).
Verified end-to-end against a live hub: `hark init --handle @x --channel
@general --dialect cite` joins and returns a handle; recv shows presence;
a second handle coexists; `dialect list` errors clearly; a non-existent
channel is rejected (no half-joined agent); key rotation for an enrolled
handle is correctly refused (bad-signature); key files are 0600.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires SPEC-003 REQ-002/005/007 into the chat transport. A capable agent now
answers asks decentrally, with no coordinator.
- chat_responder.rs: the decision core (pure, unit-tested). Lang-only — only
`(lang <dialect> <inner>)` asks whose dialect is in the agent's advertised
set are answerable; bare cite/poll are the hub's chat verbs, ignored. Built
on the canonical cbcl_core::Message API (dialect_name / innermost_simple /
thread + the cbcl-chat :from convention) so hark reads messages exactly as
the hub does.
- Flow: broadcast `(claim @ch :on "<ask_id>" :from @h)`, collect claims for Δ,
then RendezvousHash-rank the contenders. Rank 0 delivers the ask to `recv`;
a rank-k agent that sees no `reply` on the thread within k·T takes over
(liveness fallback). A reply cancels pending work. ask_id = the ask's
:thread, else a content hash.
- chat.rs receive loop drives it: claims signed+sent inline, Δ/T timers via a
FuturesUnordered, and ONLY elected asks are enqueued to `recv` (REQ-002
filters everything else). recv/reply unchanged.
- config: [chat] claim_window_ms (Δ=400) and liveness_timeout_ms (T=2000).
- VRF selector stays Phase 3 / Tier-1-gated; the claim round is VRF-less.
Live-verified end-to-end against a running hub (with the lang-aware from-handle
fix): two agents both advertising `summarize`, a lang-wrapped ask injected ->
exactly one elected, the other receives nothing. 149 lib tests + 1 live test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SPEC-003 REQ-011 proactive path, and the one primitive a concierge agent
(SPEC-003 ADR-007) needs: emit an arbitrary validated (lang ...) message, not
only reply/error/progress. hark's send path forces a reply/error/progress shape
and rejects Dialect/Wrapped envelopes (UnsupportedWrapper), so a dispatch could
not go out before this.
Packaged as a new *kind* on the existing /send, not a new endpoint or CLI verb
(it's the smallest surface; agents send it via the local API):
- cbcl_validation::validate_for_dispatch — runs the full R1–R5 pipeline against
the agent's learned dialects but does NOT force a reply shape or :thread, and
accepts a (lang ...) Dialect/Wrapped message. Refuses (meta ...) and malformed.
Not an R5 bypass: a known-dialect shape violation is still rejected (tested).
- local_api: SendMessageKind::Dispatch (serialises as "dispatch") + a branch in
the /send handler routing it to validate_for_dispatch. V1 does not append a
dispatch to the per-handle causal store (a proactive ask is not part of the
agent's reply chain). No new route/endpoint/client method/CLI subcommand.
Live-verified (on the equivalent pre-refactor path): `@disp` emitted
`(lang summarize (do @general ... :from @disp :thread t1))`; the hub fanned it and
a summarize-capable responder was elected and recv'd it — the full concierge
mechanism. 152 lib tests pass (3 new), clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"dispatch" collided with SPEC-003's established term: capability dispatch is the
claim-round routing of an ask to a capable answerer (ADR-002, cbcl-router as
"the central dispatcher"). Naming the agent's proactive send "dispatch" overloaded
that and named a generic primitive (emit any agent-originated message) after one
consumer (the concierge).
Renamed the kind to `emit`, which names the generic capability without collision:
- SendMessageKind::Emit (wire value "emit"); validate_for_emit.
- "dispatch" is reserved for SPEC-003 capability routing and for the concierge's
own act of routing intent to specialists (a fair use at that layer).
Pure rename — no behaviour change. 152 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The load-bearing first step of collapsing hark's two transports (bearer-token
router + bare-payload chat) onto the one SPEC-012 signed-member wire. src/
signed_frame.rs is the Rust peer of the LFE cbcl-signed-frame (cbcl-bus): the
wire frame len(4) ‖ seq(8) ‖ payload ‖ sig(64) and the domain-separated envelope
DS_TAG ‖ hub_id ‖ audience ‖ seq ‖ conn_nonce ‖ payload (length-prefixed).
Proven byte-for-byte identical to the LFE codec via envelope_matches_lfe_bus
(hardcoded known-good vector). Purely additive; full lib suite still green. The
transports (router.rs + chat.rs), conn_nonce/seq, the signed hello, and dropping
the Bearer header come next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Rust peer of cbcl-frame-verify's signing side: SignedConn captures the hub's
conn_nonce + hub_id at connect and folds them (plus a per-connection monotonic
seq) into every frame's Ed25519 envelope signature. Adds the conn-nonce bootstrap
parser, the signed-member router hello builder ((hello @router :from @<id>
:key "..." :dialects (...)) replacing the old unsigned (lang cbcl-router (tell
@router "hello" ...))), and the router audience (the substrate handle "router" —
every agent->router frame resolves to it).
Test signed_frame_verifies_as_the_hub_would proves a SignedConn frame verifies
against the agent's key over the exact envelope the hub reconstructs. 159 lib
tests green. Wiring into router.rs/chat.rs (dropping the Bearer header) is 6c/6d.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gated (CBCL_ROUTER_WS) end-to-end test: connect to a running router, receive
the conn-nonce bootstrap, send a SignedConn-signed hello + a signed
(meta (query (list))), and assert the hub accepts both (a reply comes back, no
bad-signature). Ran green against the cbcl-bus cbcl_router release on
ws://localhost:18080/agent/v1 — proving hark's 6b signing core interoperates
with the LFE hub over the signed-member wire, with NO bearer token. De-risks the
production router.rs rewrite (6c).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire hark's router transport to SPEC-012. connect() drops the
Authorization: Bearer header (REQ-009: no bearer/challenge-response connection
auth — TLS secures the channel, identity is per-frame by signature).
create_router_agent now: receives the hub's conn-nonce bootstrap, builds a
SignedConn, sends a per-frame-signed (hello @router :from @<id> :key "..."
:dialects (...)), and threads the SignedConn + the agent's Ed25519 identity into
the receive loop, which signs EVERY outbound path (heartbeat, the send channel,
auto-rehello) over the domain-separated envelope.
6e (config): validate_router no longer requires auth_token (a legacy token is
accepted but ignored); add default_router_identity_path() — the daemon's stable
router key (replacing the bearer credential), TOFU-enrolled by the hub. local_api
loads/creates it and passes it in.
Validated end-to-end: tests/signed_handshake_live passes against the live
cbcl_router hub (ws://localhost:18080/agent/v1). 159 unit tests green; all test
targets compile clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move hark's chat transport off the old bare-payload codec onto the same
SignedConn envelope wire as the router (6c). create_chat_agent now captures the
chat hub's conn-nonce bootstrap and signs the hello + every outbound frame over
the domain-separated envelope; both transports share one signing core, differing
only in audience derivation:
- router: audience = the substrate handle "router" (sigil stripped)
- chat: audience = the message's recipient WITH its @ sigil (sign_chat_frame
derives it per frame, matching cbcl-core-msg:room)
parse_conn_bootstrap accepts the chat @client bootstrap as well as the router
@agent one.
Fix: the chat hub bare-frames its hub->client frames (len ‖ payload ‖ sig),
unlike the router's raw text — recv_bootstrap now decode_payloads before reading
the bootstrap text (caught by live validation: the chat hello was never reaching
the hub until the bootstrap parsed).
Validated end-to-end: tests/signed_handshake_live passes for BOTH the router
(ws://localhost:18080/agent/v1) and the chat hub (ws://localhost:8080/chat/v1) —
signed hello accepted, join/reply received, no bearer token. 161 unit tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /agent/v1 connection no longer uses a shared-secret bearer token; identity is
per-frame Ed25519 over a domain-separated envelope. Update the docs to match:
- router-protocol.md: rewrite Authentication (conn-nonce bootstrap + signed
hello, no Authorization header) and the Hello Frame (new shape
(hello @router :from @<id> :key "..." :dialects (...)), signed over the
envelope; capability ≡ dialect).
- config.md: drop auth_token from the example; document the router identity key
(<config-dir>/router-agent.key, TOFU-enrolled); auth_token accepted-but-ignored;
fix the Future Auth / Error Handling sections.
- local-api.md + README: router_auth_rejected now means a proxy 401/403 (the hub
has no connection auth); missing_router_auth_token is legacy. Drop the
auth-token config/env/troubleshooting lines.
- README callout: both transports now share one signed-member envelope wire,
selected by the ws_url path; validated live against running hubs.
The daemon's *local loopback* bearer token (daemon.md / local-api.md) is
unrelated and unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After moving the router to per-frame Ed25519 auth, two things became dead:
- ConfigError::MissingRouterAuthToken — never constructed (validate_router no
longer requires a token). Removed the variant, its local-API mapping
(missing_router_auth_token), and the cli error-code match arm.
- ValidatedRouterConfig.auth_token — computed but never read (connect no longer
sends an Authorization header). Removed the field; validate_router returns
just { ws_url }; its Debug redaction is gone (no secret to redact).
Kept (still live): RouterConfig.auth_token is parsed but ignored, so old configs
still load (and it stays redacted in Debug); RouterError::AuthRejected remains —
it fires on a 401/403 WebSocket-upgrade refusal (e.g. a proxy), which the hub
itself never produces. Clarified its message + added a doc comment.
161 unit tests green; all test targets compile clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review follow-ups:
- recv_bootstrap (router + chat) wrapped the hub's first-frame read in no timeout:
a hub that accepts the WebSocket upgrade then stalls would hang
create_*_agent forever, blocking the local-API request. Bound it (10s) and fail
closed with "timed out waiting for the conn-nonce bootstrap".
- signed_frame::decode_frame computed `len + SIG_LEN` without checked_add (a u32
len near usize::MAX could overflow/panic on 32-bit); now matches the defensive
chat_frame::decode_payload with `checked_add`.
- removed the unused SignedConn::next_seq.
161 unit tests green; all test targets compile clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three hark-owned specs under USDD (intent-anchored):
- SPEC-013 (Tier-1, no-go): MLS so an agent can join encrypted private
channels — binds the MLS leaf to the wire Ed25519 key; one group per
connection; cbcl-bus/cbcl-chat affected.
- SPEC-014 (Tier-1, no-go): host->agent delegation — a signed, verifiable
"agent A acts on behalf of host H" carried on announce; identity only.
- SPEC-016 (Tier-3): onboarding DX — one-shot hark join, in-app pairing,
hark say, and selective dialect auto-learn (chosen subset, validated
against the channel's declared menu via SPEC-015).
All draft; the two Tier-1 specs gate on cross-model adversarial review +
human crypto sign-off before implementation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- REQ-009: an agent responds only to asks in dialects the channel declares
AND it speaks (it may speak more elsewhere); empty declared set => empty
response scope. (The agent-side scoping model, hub stays non-gating.)
- REQ-007 / ADR-003: the pairing token is bound to {channel, invite-cap,
chosen (name, digest) dialects}; hark pair installs + advertises them by
digest (no --speak). The short code references a hub-minted pairing record;
OQ-001 now covers the mint/redeem endpoints + record format.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- REQ-010: an agent's channel membership records added_by (the member who
paired/invited it) as per-channel provenance, distinct from the agent's
SPEC-014 host (who it acts for); roster displays both. Pairing record (REQ-007/
OQ-001) carries the adder.
- OQ-004: agent-removal authz (added_by member vs host vs any member) — mirrors
dialect delete-by-adder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SPEC-016 v0.2.0: decouple from host delegation. REQ-006 legibility = just
"render as an agent"; REQ-010 = added-by provenance (no host claim); new
REQ-011 (adder sets the agent's channel handle, carried by the pairing token,
--as override); REQ-007/OQ-001 pairing record carries the name; OQ-004 drops
the host option.
- SPEC-014 marked superseded: the chat model identifies an agent by its own key
+ handle and attributes it by added-by, so the host->agent delegation isn't
needed. A Tier-1 no-go crypto spec drops off the path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SPEC-016 v0.3.0, status approved (Tier-3 DX; the SPAKE2 pairing handshake is the
one Tier-1 carve-out). + the decisions record.
- OQ-001: pairing = memorable BIP39 phrase + SPAKE2 (reusing cbcl-crypto-spake2),
releasing a record that wraps a cbcl-chat-invite cap + {name, dialects}. Room
admission reuses invite-as-cap. The handshake is Tier-1/no-go.
- OQ-002: hark emit exposes the existing kind=emit; wire frames stay valid CBCL
((tell ...)), the chat client unwraps for display.
- OQ-003: self-hosted curl install (files.anuna.io/hark/install.sh); no Homebrew/
releases; curl skips macOS quarantine.
- OQ-004: an agent is removable only by its added_by member (mirrors dialects).
DX scope is implementation-ready; the SPAKE2 pairing gates on its Tier-1 review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-model review found the core defect: MLS membership is anchored to
hub-mediated TOFU and unchecked KeyPackages/Welcomes, not to authenticated wire
identity. Tier-1 stays BLOCKED; folded all findings into requirements.
New/strengthened requirements:
- REQ-008 (BUG-001): adder verifies target handle AND leaf key == PINNED wire key
(not hub-asserted).
- REQ-011 (BUG-003): pin handle->wire-key from the member's own signed frames,
never hub assertion.
- REQ-012 (BUG-002): app-bound Welcome validation (room + authorised committer;
no silent group replacement).
- REQ-013 (BUG-004): enforced single-use KeyPackages; bound last-resort reuse.
- REQ-014 (BUG-005): MLS Commit-out on room removal (removal moved into scope).
- REQ-015 (BUG-006): validate keypub/keyget inputs before mutating directory.
- REQ-016 (BUG-007): roster/committer authenticity; split-group resistance.
- NFR-004: precise local-compromise model + retention policy.
OQ-001 (key reuse): no collision found — acceptable pending a regression test.
OQ-002 (authenticated trust root) + OQ-003 (roster authenticity) remain OPEN and
gate sign-off. Re-review of v0.2.0 required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-2 found round-1's REQs protected the adder's local path but not inbound
membership changes, sender authenticity, or the wire support REQ-011 assumes:
- REQ-017 (BUG-008): inspect every inbound Commit + pending proposal BEFORE merge;
Add leaves must satisfy target+pinned-key; reject unauthorized proposals.
- REQ-018 (BUG-009): bind decrypted :from to the authenticated MLS sender leaf
(reject mismatches) — stops member :from forgery.
- REQ-019 (BUG-010): a peer-verifiable identity/key-assertion wire contract;
REQ-011's pinning is not implementable on today's hub-attested wire without it.
- REQ-013 corrected: single-use enforced CLIENT-side (transcript-visible
KeyPackageRefs), not at the untrusted directory; OQ-004 reopened.
- REQ-020 (BUG-011): until the gate clears, the UI must not claim E2EE for private
channels — IMMEDIATE action on the live deployment.
OQ-002/003/004 OPEN. Tier-1 BLOCKED; round-3 re-review required.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bug class was the missing MLS Authentication Service (the hub is the
untrusted Delivery Service). v0.4 designs it:
- ADR-006 / OQ-002: AS = invite-anchored TOFU + safety numbers (humans) +
SPAKE2 pairing (agents). NOT admin-rooted (a central authority re-introduces
a hub-like trusted party, antithetical to peer E2EE); key transparency is the
documented strong-future (TOFU pins are forward-compatible).
- REQ-019 sharpened: self-signed (idkey @handle :key K :room :nonce) assertion,
peer-verified (not hub-attested) — makes REQ-011 implementable.
- REQ-021: safety-number (fingerprint) verification — human first-contact MITM check.
- REQ-016 / OQ-003: committer + add/remove authority derive from the verifiable
MLS ratchet tree, not hub presence; deterministic committer over MLS leaves.
OQ-002/003 now PROPOSED (resolved-direction); OQ-004 (replay) OPEN. Round-3
cross-model review + human crypto sign-off still gate implementation; REQ-020
(live-UI claim) still pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move KeyPackage replay defence client-side (the directory is the untrusted
hub): delete-on-use of one-time init private keys + consumed-KeyPackageRef
ledger + transcript-visible refs (REQ-013); one-time replenishment with a
bounded, documented last-resort whose forward-secrecy residual is explicitly
accepted (REQ-022). Resolve OQ-005 by retention minimisation — keep only
current-epoch secrets, prune superseded, version bump = re-join (NFR-004).
All five OQs are now RESOLVED-direction (PROPOSED). Remaining gate: round-3
cross-model review + human crypto sign-off; REQ-020 live-UI fix independent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-3 adversarial review (docs/decisions/SPEC-013-round3-review-findings.md)
found 2 Critical + 4 High. Fold each disposition into the specs:
SPEC-013 v0.6.0:
- R3-05 (Crit) → REQ-023 (new): pin channel enc mode; fail closed on hub downgrade.
- R3-07 (Crit) → REQ-017: validate every leaf-changing object (Add/Update/Remove +
UpdatePath) + credential immutability; closes reopened :from forgery.
- R3-06 → REQ-014: authenticated removal evidence, not hub presence.
- R3-08 → REQ-012/016: checkable creator bootstrap + full-tree leaf-vs-pin.
- R3-01 → ADR-006 re-scoped (SPAKE2 = capability, not agent identity).
- R3-09/10/11/12/13/14 → REQ-013/022/006/021, NFR-004, REQ-019/OQ-001;
new REQ-024 (hark safety-number surface).
SPEC-016 v0.4.0:
- R3-02/03/04 → REQ-007 + ADR-003 rewritten: storage is password-equivalent
(not a one-way HMAC), pairing-specific transcript constants, failed-attempt
bound, release bound to PAKE key K, enc-mode field in the pairing record.
Gate stays BLOCKED: dispositions folded, not cleared — round-4 confirmation +
human crypto sign-off still required (SPEC-013 §8).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Isolated, gate-permitted experiment (detached crate, no production code). Pins
openmls 0.8.1 to match cbcl-mls-wasm and turns the round-3 "could NOT assess"
items into observed evidence:
- R3-07 (Crit): OpenMLS ACCEPTS a self-Update rebinding a leaf credential
bob->alice, and the peer accepts it — empirically confirms REQ-017's
credential-immutability clause is load-bearing (RFC 9420 does not enforce
credential continuity across Update; the app must).
- R3-10: KeyPackageIn::validate() REJECTS a 1970-expired KeyPackage
(InvalidLifetime) — REQ-022's lifetime bound is enforced by the primitive.
- R3-11/OQ-005: max_past_epochs bounds the past-message window (0 -> undecryptable,
1 -> decryptable); confirmed knobs max_past_epochs / number_of_resumption_psks /
sender_ratchet_configuration on MlsGroupJoinConfigBuilder — NFR-004 can cite
these concretely. Durable-provider disk-delete remains unassessable in-memory.
- NFR-001: create->add->welcome->encrypt->decrypt round-trips through bytes at the
pinned ciphersuite; authenticated sender leaf resolves (the REQ-018 hook).
Running (not just compiling) caught two probe bugs that would have produced false
evidence — Lifetime::new vs init, and max_past_epochs belonging on the decrypting
group's JoinConfig — both fixed and recorded in the README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1. Storage-pruning probe (R3-11 mechanism). Enables openmls_rust_crypto/test-utils
to read MemoryStorage byte size. Entry COUNT is a false metric (past epochs live
inside one serialized blob); persisted BYTES is the right one. After 12 epoch
changes: max_past_epochs(0) -> ~8.4 KB vs (12) -> ~36 KB (~2.3 KB/epoch retained).
=> OpenMLS prunes superseded epoch secrets from PERSISTED state, not just memory;
a durable provider (ADR-004) honouring deletes inherits the bound. Residual
narrowed to that provider's own on-disk fsync.
2. Cross-stack native <-> real .wasm harness (NFR-001). build-wasm.sh compiles the
actual cbcl-mls-wasm for nodejs (same openmls 0.8.1 + wasm-bindgen 0.2.114);
src/bin/native_peer.rs is a JSON-line stdio peer driving native OpenMLS;
cross-stack/cross_stack.mjs drives the wasm binding. Both directions PASS:
native->wasm (Welcome + app msg) and wasm->native (app msg). This is the genuine
cross-stack confirmation (native vs the shipped artifact), not native<->native.
Generated wasm-node/ is gitignored (rebuild via build-wasm.sh). Full suite: 6 Rust
probes + the node harness all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assemble the round-4 confirmation packet:
- SPEC-013 v0.6.1: thread the §10 spike results into REQ-017 (R3-07 confirmed
empirically — credential-rebinding self-Update accepted, clause (c) load-bearing),
REQ-022 (validate() rejects expired KeyPackage), NFR-004 + OQ-004/005 (retention
knobs confirmed, pruning reflected in persisted state), and §8 (spike DONE, what
it did/didn't close). OpenMLS-primitive items the round-3 review flagged as
unassessable are now confirmed; residuals narrowed to the durable provider's
on-disk fsync + cbcl_ristretto point validation.
- Round-3 findings doc: add a §10-spike subsection mapping each could-not-assess
item to its evidence.
- New round-4 review brief (fresh-context / cross-model, Principle 12): focused not
on re-running round-3 but on confirming the v0.6 dispositions actually close
R3-01..R3-08 AND hunting the new gap each fix might open (first-contact mode TOFU,
Update vs legitimate rotation, genesis-assertion forgeability, bye liveness,
safety-number epoch churn, enc-field authenticity). Spike evidence is a provided
input; app-level closures are the reviewer's job.
Gate still BLOCKED: round-4 + human crypto sign-off outstanding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 4 confirmed R3-01/R3-02 closed but found 1 Critical + 3 High +
1 Medium + 1 Low (findings doc + the prompt that produced them recorded
under docs/decisions/). Dispositions folded:
- R4-01 (Crit) → REQ-023 rewritten: the encryption pin derives from the
admission path (cap/invite ⇒ private ⇒ encrypted, pinned pre-send) or
explicit operator intent; first-observation mode-TOFU removed; unknown
private mode fails closed. SPEC-016 v0.5.0: the pairing `enc` field is
advisory; the invite-cap presence is the signal.
- R4-02 (High) → REQ-014/REQ-017(d): signed removal-evidence object
(own DS label, room/group/epoch/target-bound), verified at merge by
every validator; the bye is fanned as evidence (affected-repo change);
creator as liveness-fallback removal authority (PROPOSED).
- R4-03 (High) → REQ-016: genesis authoritative only with a pinned or
independently authenticated creator key, else documented
first-group-wins TOFU; durable delivery via a GroupContext app
extension; invite links SHOULD carry the creator-key fingerprint.
- R4-04 (High) → REQ-021/REQ-024: stable identity safety number split
from the volatile epoch state hash so out-of-band comparison survives
normal Commits.
- R4-05 (Med) → REQ-011/REQ-017(e): cross-signed rekey rotation
ceremony; fail-closed proposal allowlist.
- R4-06 (Low) → OQ-001 condition (2) marked done (code lands separately).
Gate stays BLOCKED pending round-5 confirmation + human crypto sign-off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chat_frame::encode_frame signed arbitrary caller-chosen bytes with the
wire identity key under no domain separation — the raw-bytes signing
oracle OQ-001 conditions ADR-002's key reuse on retiring. Production
already signs only the domain-separated envelope via SignedConn; the
sole remaining caller was the (stale) live responder test, which signed
bare payloads the envelope-verifying hub now rejects anyway.
Remove encode_frame + NullSigner; keep decode_payload (hub→client
framing) and the FrameSigner seam (fed only envelope bytes by
SignedConn). The live test now bootstraps the signed-member wire like
production. Module docs prohibit reintroducing a bare-payload encoder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paste-ready brief for the round-5 confirmation (fresh context,
different model — Principle 12): trace REQ-011/014/016/017/021/023/024
to mechanisms closing R4-01...R4-05, hunt the new gap each fix may have
opened, and explicitly endorse/reject the two author design calls
(D-1 admission-path encryption pin; D-2 creator as removal-authority
liveness fallback). Verdict gates on the updated residual list the
human signer must accept.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native probes (tests/genesis_extension.rs) + cross-stack harness
(genesis_probe.mjs) at openmls 0.8.1: Unknown(0xF013) GroupContext extension
round-trips create→add→welcome→read with leaf capabilities advertised
(pre-finalize read from StagedWelcome confirmed); the REAL shipped
cbcl-mls-wasm KeyPackage fails closed (InsufficientCapabilities); method
nuance — a default-capability creator is accepted at MlsGroup::new and the
group bricks on its first path-commit, so the create config must set the
capability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign-off record (docs/decisions/SPEC-013-tier1-signoff.md): residuals A–H
explicitly accepted by the project owner in a structured walk-through;
D-1/D-2 ratified; ADRs APPROVED conditional. Conditions: K round-6
independent-model spot-check (prompt drafted, blocks implementation merge —
Principle 12), A-t no-collision property test, I durable-provider delete
fidelity, J cbcl_ristretto audit (binds IMPL-016). v0.7.2 R5-03 probe
evidence threaded into §10/REQ-016; SPEC-016 → v0.6.0 (pairing handshake
conditionally cleared with SPEC-013).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Condition K satisfied: independent spot-check confirmed R5-01 (closed, with
the retry-cost caveat), R5-02, R5-03, and endorsed D-1/D-2 — no re-block
(docs/decisions/SPEC-013-round6-spotcheck-findings.md). Carries folded:
K-1 remove-race retry behaviour + concrete-leaf binding clarification
(REQ-014, §9 test); K-2 creator-capability creation-time guard (REQ-016,
§9 test). Gate: CLEARED for implementation; IMPL-bound conditions A-t, I,
J, K-1, K-2. SPEC-016 → v0.6.1 (pairing handshake cleared, J binds
IMPL-016).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>