Found while auditing CIRISPersist for v31.0.0 (CIRISAI/CIRISPersist#660). persist's
own path is closed; the verify-side re-derivation is not, and it has the same
shape.
The class
Every authority gate we audited answered "who signed?". Almost none answered
"who is this ABOUT?". A quorum verifies m-of-n over JCS(envelope) and
nothing else — so if the value naming the subject is not inside those bytes,
the same valid signature applies to any subject.
That single question found four planes in persist. The worst, verified against
the strict gate (withdrawal-wins, the #513 ≥3-FIPS floor, real quorum crypto):
PROBE key_id = canon-probe-ATTACKER
envelope = {"key_id": "canon-probe-victim"}
RESULT Ok(())
An accord co-scrub conferred canonical on any key_id. That is the only
door canonical, infra:attest, the co-steward roles and every
AUTHORITY_CONFERRING_IDENTITY_TYPES member enter through. #513 prices a trust
root at three non-virtualizable humans; unbound, that price bought an unbounded
number of conferrals.
Where CIRISVerify is exposed
ProvenanceChain is consumed here and in CIRISEdge, and each link's signature is
re-verified against the next link's pubkey. That is signer-only verification:
the same shape. A hostile peer serving a fabricated chain could lift a signature
exactly as persist's gate allowed, because nothing in the verified bytes pins
which link the signature is about.
persist's in-repo rows are all bound at the write gate now, so rows originating
here are safe. The re-derivation on the verify side is CIRISVerify's to bind,
and it runs against chains persist did not author.
The fix shape that worked, and two refinements that cost us time
persist's remedy is admission::subject_binding — one projection, called by
both the producing and the checking side:
- Bind the identity, not just the name. Binding
key_id alone still loses
to a race: on a node that has not yet replicated the victim's row, an attacker
registers the victim's key_id with their own pubkeys and the conferral
succeeds. Bind both hybrid legs, with absence asserted as JSON null, so
substituting a PQC key the quorum never covered is refused exactly as a
differing string is.
- Have the checking side ITERATE the projection, rather than check named
fields. Then adding a member extends the check with no second edit —
exhaustive by construction rather than by anyone remembering. This is the
single detail that made the fix safe to reuse across four planes unchanged.
- Fail CLOSED on absence. An envelope not carrying the binding is REFUSED,
never tolerated. A tolerated absence is how our first attempt got left
half-done — "an optional check is skippable by omission, which is the whole
attack."
- Check the binding FIRST, before roster or custody resolution, so the
refusal is deterministic regardless of that state.
One trap worth inheriting: "this record confers nothing today" is not a
reason to skip binding it. In persist a plain KeyRecord becomes a conferral
subject through two separate elevation paths, one of which skips the
registration gate entirely for exactly the role-gated records.
Ask
- Audit
ProvenanceChain's re-verification for subject-blindness and bind it.
- Sweep the same question across any other verify-side quorum or signature
walk: does anything in the verified bytes pin who this is about?
- Tell us if binding it changes a wire shape — persist is mid-mesh-reset and
this is the cheap window. After 31.1.0 bakes the new trust root, a preimage
change costs a second ceremony against a live federation.
Reference: CIRISAI/CIRISPersist#660, and issue CIRISAI/CIRISPersist#659 for the
full write-up and probe.
Found while auditing CIRISPersist for v31.0.0 (CIRISAI/CIRISPersist#660). persist's
own path is closed; the verify-side re-derivation is not, and it has the same
shape.
The class
Every authority gate we audited answered "who signed?". Almost none answered
"who is this ABOUT?". A quorum verifies m-of-n over
JCS(envelope)andnothing else — so if the value naming the subject is not inside those bytes,
the same valid signature applies to any subject.
That single question found four planes in persist. The worst, verified against
the strict gate (withdrawal-wins, the #513 ≥3-FIPS floor, real quorum crypto):
An accord co-scrub conferred
canonicalon anykey_id. That is the onlydoor
canonical,infra:attest, the co-steward roles and everyAUTHORITY_CONFERRING_IDENTITY_TYPESmember enter through. #513 prices a trustroot at three non-virtualizable humans; unbound, that price bought an unbounded
number of conferrals.
Where CIRISVerify is exposed
ProvenanceChainis consumed here and in CIRISEdge, and each link's signature isre-verified against the next link's pubkey. That is signer-only verification:
the same shape. A hostile peer serving a fabricated chain could lift a signature
exactly as persist's gate allowed, because nothing in the verified bytes pins
which link the signature is about.
persist's in-repo rows are all bound at the write gate now, so rows originating
here are safe. The re-derivation on the verify side is CIRISVerify's to bind,
and it runs against chains persist did not author.
The fix shape that worked, and two refinements that cost us time
persist's remedy is
admission::subject_binding— one projection, called byboth the producing and the checking side:
key_idalone still losesto a race: on a node that has not yet replicated the victim's row, an attacker
registers the victim's
key_idwith their own pubkeys and the conferralsucceeds. Bind both hybrid legs, with absence asserted as JSON
null, sosubstituting a PQC key the quorum never covered is refused exactly as a
differing string is.
fields. Then adding a member extends the check with no second edit —
exhaustive by construction rather than by anyone remembering. This is the
single detail that made the fix safe to reuse across four planes unchanged.
never tolerated. A tolerated absence is how our first attempt got left
half-done — "an optional check is skippable by omission, which is the whole
attack."
refusal is deterministic regardless of that state.
One trap worth inheriting: "this record confers nothing today" is not a
reason to skip binding it. In persist a plain
KeyRecordbecomes a conferralsubject through two separate elevation paths, one of which skips the
registration gate entirely for exactly the role-gated records.
Ask
ProvenanceChain's re-verification for subject-blindness and bind it.walk: does anything in the verified bytes pin who this is about?
this is the cheap window. After 31.1.0 bakes the new trust root, a preimage
change costs a second ceremony against a live federation.
Reference: CIRISAI/CIRISPersist#660, and issue CIRISAI/CIRISPersist#659 for the
full write-up and probe.