feat(14.2.0): fedcode carries a PQC commitment, so a code-admitted key is registrable (#272) - #273
Conversation
…y is registrable (#272) A fedcode carries an Ed25519 identity and nothing else, but CIRISPersist takes federation_keys writes only at algorithm: "hybrid". So a code-admitted key had NO PQC HALF TO REGISTER, ContactResolution::ReadyFromCode was unreachable, and first contact had no working path at all. Reported end-to-end from a live 3-node mesh: an owner-binding written at cohort_scope: self is correctly withheld from every peer, so nodes_owned_by() is permanently empty on the far side, and every chat: row is withheld in BOTH directions -- including the MLS KeyPackage that would key the room. Two nodes that had peered, consented and derived the same community id could not exchange one message, with nothing in the path malfunctioning. v14.1.0's v3 code was the designed way out. It could not be taken. THE FIX -- commit, do not inline. An ML-DSA-65 public key is 1952 bytes; inlining takes a code past 3KB and ends its life as something a person can type or read aloud. So the code carries sha256(ml_dsa_65_pubkey), 32 bytes. The host admits the classical half plus the digest, pulls the ML-DSA body through the existing Key Pull, and verifies it against the commitment BEFORE writing. Persist's hybrid rule is preserved, not weakened: nothing registers until both halves are present and bound. Same pattern as #113/#116, which committed this exact key for the same size reason. ONE CORRECTION TO THE PROPOSAL AS FILED. It scoped the commitment to the v3 node tail. But the admission gap affects EVERY code, and a node-free `user` code is the shape most likely to be handed over at first contact -- so either trigger emits v3, and a commitment-only code is valid with node_count = 0. Scoping it to node-carrying codes would have left the most common first-contact shape unadmittable. COMPATIBILITY. Presence-tagged and LAST, so a v3 code minted by v14.1.0 -- which ends after the node list -- still decodes with the commitment absent. Asserted by a test that hand-builds the pre-#272 payload rather than trusting the encoder. A code with neither nodes nor a commitment still emits byte-identical v2. verify_pulled_ml_dsa_65_pubkey is the one blessed check. It FAILS CLOSED on a code with no commitment -- there is nothing to bind the pull to -- and compares in constant time, since the match gates admission. Trust level is stated in the FSD rather than left to be assumed: a fedcode is UNSIGNED, so the commitment inherits exactly the code's trust and adds no authority of its own. What it buys is that a Key Pull cannot be substituted after the fact. A conforming impl MUST NOT read a match as authentication. FSD-003 §3A.5 normative. 9 tests; 1487 workspace green, clippy clean, doc clean, guards pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4djyL5Vx6whagKB7J4586
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…nert create_federation_identity mints a hybrid identity and was passing ml_dsa_65_pubkey_sha256: None -- so every code verify itself mints would still have been unadmittable, and the whole #272 fix inert for exactly the population that matters. This is the same dead-branch class as v14.1.0's VerifiedJson: a capability added, tested in isolation, and never reached by the real producer. Caught by asking the question that fix taught me to ask. The commitment is read back off the record just built, so the code's commitment and the REGISTERED ML-DSA key are provably the same bytes rather than two independent derivations that could drift. A record with no PQC half now refuses to mint a code at all, rather than minting one that could never be admitted. BEHAVIOR CHANGE: a minted identity code is now CIRIS-V3-, not CIRIS-V2-. An existing test asserted v2; it encoded the old behavior. A consumer older than v14.1.0 rejects V3 outright, which is the safe direction -- it could not have admitted the key either. Round-trip asserted through the real producer: the minted code's commitment matches the registered key, and verify_pulled_ml_dsa_65_pubkey accepts it. 1487 workspace green, clippy clean, doc clean, guards pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4djyL5Vx6whagKB7J4586
|
Pushed a second commit — the fix was inert as first written.
That is the same dead-branch class as v14.1.0's The commitment is now read back off the record just built, so the code's commitment and the registered ML-DSA key are provably the same bytes rather than two independent derivations that could drift. A record with no PQC half refuses to mint a code at all. Behavior change worth flagging: a minted identity code is now Round-trip now asserted through the real producer: the minted code's commitment matches the registered key, and |
`fedcode new` mints an Ed25519-only software identity, so it legitimately cannot carry a PQC commitment -- and therefore produces a code that will be REFUSED at admission, which is the exact confusion #272 describes. Better to learn that at the terminal than after handing the code to someone at first contact, so `fedcode show` now says so and points at `identity create` for a hybrid identity. 1487 workspace green, clippy clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4djyL5Vx6whagKB7J4586
Closes #272.
A fedcode carries an Ed25519 identity and nothing else, but CIRISPersist accepts
federation_keyswrites only atalgorithm: "hybrid". So a code-admitted key had no PQC half to register,ContactResolution::ReadyFromCodewas unreachable, and first contact had no working path at all.The report is the good kind — measured end-to-end on a live 3-node mesh, with nothing in the path malfunctioning: an owner-binding written at
cohort_scope: selfis correctly withheld from every peer, sonodes_owned_by(peer)is permanently empty on the far side, and everychat:row is withheld in both directions — including the MLS KeyPackage that would key the room. Two nodes that had peered, consented and derived the same community id could not exchange one message.v14.1.0's v3 code was the designed way out of that, and could not be taken.
The fix: commit, don't inline
An ML-DSA-65 public key is 1952 bytes — inlining it takes a code past 3 KB and ends its life as something a person can type or read aloud. So the code carries
sha256(ml_dsa_65_pubkey), 32 bytes.The host admits the classical half plus the digest, pulls the ML-DSA body through the existing Key Pull, and verifies it against the commitment before writing. Persist's hybrid rule is preserved, not weakened — nothing registers until both halves are present and bound.
This is Option A from the issue, and the same pattern as #113/#116, which committed this exact key for the same size reason.
One correction to the proposal as filed
It scoped the commitment to the v3 node tail. But the admission gap affects every code, and a node-free
usercode is the shape most likely to be handed over at first contact. Scoping it to node-carrying codes would have left the most common first-contact shape unadmittable.So either trigger emits v3, and a commitment-only code is valid with
node_count = 0.Compatibility
The check
fedcode::verify_pulled_ml_dsa_65_pubkey(&code, pulled)is the one blessed path. It fails closed on a code with no commitment — there is nothing to bind the pull to — and compares in constant time, since the match gates admission.Trust level, stated rather than assumed
A fedcode is unsigned. The commitment inherits exactly the trust of the code carrying it and adds no authority of its own; what it buys is that a Key Pull cannot be substituted after the fact. FSD-003 §3A.5 records this normatively, including that a conforming implementation MUST NOT read a match as authentication of the identity.
9 new tests. 1487 workspace green, clippy clean, doc clean, guards + self-test pass.
For CIRISServer: this unblocks
ReadyFromCode. The501 contacts.code_admission_unavailablerefusal was the right call while the gap existed — the flow is now Key Pull →verify_pulled_ml_dsa_65_pubkey→ hybrid write.🤖 Generated with Claude Code
https://claude.ai/code/session_01U4djyL5Vx6whagKB7J4586