Skip to content

feat(14.2.0): fedcode carries a PQC commitment, so a code-admitted key is registrable (#272) - #273

Merged
emooreatx merged 3 commits into
mainfrom
fix/272-fedcode-pqc-commitment
Sep 4, 2026
Merged

feat(14.2.0): fedcode carries a PQC commitment, so a code-admitted key is registrable (#272)#273
emooreatx merged 3 commits into
mainfrom
fix/272-fedcode-pqc-commitment

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

Closes #272.

A fedcode carries an Ed25519 identity and nothing else, but CIRISPersist accepts 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.

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: self is correctly withheld from every peer, so nodes_owned_by(peer) 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.

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 user code 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. The 501 contacts.code_admission_unavailable refusal 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

…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
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

…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
@emooreatx

Copy link
Copy Markdown
Contributor Author

Pushed a second commit — the fix was inert as first written.

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. The commitment support existed, was tested in isolation, and was never reached by the real producer.

That is the same dead-branch class as v14.1.0's VerifiedJson — which the review on #270 caught. I found this one by asking the question that fix taught me to ask, but only after opening this PR, which is the honest sequence.

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 CIRIS-V3-, not CIRIS-V2-. An existing test asserted v2 and encoded the old behavior. A consumer older than v14.1.0 rejects CIRIS-V3- outright — the safe direction, since it could not have admitted the key either.

Round-trip now asserted through the real producer: the minted code's commitment matches the registered key, and verify_pulled_ml_dsa_65_pubkey accepts it. 1487 green.

`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
@emooreatx
emooreatx merged commit 84fef3b into main Sep 4, 2026
27 checks passed
@emooreatx
emooreatx deleted the fix/272-fedcode-pqc-commitment branch September 4, 2026 04:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fedcode carries no ML-DSA half, so a code-admitted key can never satisfy persist's hybrid-only federation_keys rule

1 participant