fix: pin the grant section's signer at the adoption gate's stage 3 - #1120
Conversation
WalkthroughThe adoption gate now requires all structures in a grant section to authenticate under one committed write-capable pseudonym. Shared traversal, producer validation, deterministic engine KAT vectors, manifests, and tests enforce this rule. ChangesAdoption gate signer enforcement
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related issues
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
476358f to
491f14c
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/engine/examples/kat_gen.rs`:
- Around line 304-311: Update the rationale comment near the Stage 3
one-section-one-signer rule to replace the ambiguous “pins” wording with a plain
grammatical verb, while preserving the intended invariant that the commitment
limits how many signers a section may have, not which pseudonym may sign it.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: cee3edb3-4f3f-4492-8442-ca89f1f548ea
📒 Files selected for processing (13)
.prettierignoreblueprint/core.mdblueprint/engine.mdblueprint/testing.mdcrates/core/src/seal/section.rscrates/engine/examples/kat_gen.rscrates/engine/kat/gate/manifest.jsoncrates/engine/kat/gate/vectors/section_signer_accept.jsoncrates/engine/kat/gate/vectors/section_signer_reject.jsoncrates/engine/src/gate/adoption.rscrates/engine/src/gate/mod.rscrates/engine/src/net/author.rscrates/engine/tests/kat_gate.rs
The gate trial-verified every seed-bearing structure against every committed write-capable pseudonym independently, so a section that switched signers part-way cost the full `pseudonyms x structures` product. After the caps landed that is still 1025 x 1284 = 1,316,100 Ed25519 verifications per record, and the resume hint is steerable: signing structure k under the pseudonym one step behind the hint defeats it entirely. An accepted contact reaches that for ~1284 signatures. A section is one rotator's work — `rotation/reseal.rs` signs every structure under one writer pseudonym and re-signs even the history links it carries forward verbatim — so the gate now pins the pseudonym that authenticated the section's first structure and requires every later structure to verify under that key alone. A section signed by two committed pseudonyms becomes a whole-record trust violation. Measured on the worst case (1025 pseudonyms, 1284 structures), release build: the mixed-signer section went from 1,316,100 verifications / 74.6 s / ADOPTED to 1,026 verifications / 37 ms / `structure-signature-invalid`. The honest worst case is unchanged at 2,308 verifications / ~81 ms. `net/author.rs::check_scope_root` already ran the same predicate, so the produce-side mirror holds by construction; it gains a release-active test that a two-signer section is never signed. The invariant is now normative in blueprint/engine.md, and a new self-contained engine KAT set under `crates/engine/kat/gate/` freezes the accept and reject verdicts over whole scope-root head blocks, gated by Engine Tests. Also adds `crates/engine/kat/` to .prettierignore: the tree is generator-owned and byte-diffed by CI, so a prettier rewrite would fail a gate it cannot fix. Closes #1102
Review gates on the draft PR. /simplify + /crypto-privacy-review, KAT strength: the reject vector could not distinguish "the pin fired" from "a junk signature", and the accept vector's commitment named one pseudonym, so an over-tightening to the owner pseudonym or to index 0 would have gone uncaught. Every vector now shares one commitment naming two write-capable pseudonyms; the harness asserts each reject's every structure signature is individually valid under a committed pseudonym and that together they name two, and that one accept vector is signed throughout by a committed pseudonym that is not the owner's. Adds a structure-splice reject — a grant blob verbatim from another committed writer at the same scope and epoch, which recomputes an identical signed input — which is the integrity hole the pin closes and the only vector exercising the recipientTag arm. /simplify, efficiency: committed_write_pseudonyms decompressed up to 1025 Ed25519 points eagerly, on every resolved record, though the pin means at most one is used — an O(pseudonyms) term the pin otherwise removes. Keys stay compressed and decompress inside the trial loop; the pin holds the verifier rather than an index. Honest worst case 81.5 ms to 75.9 ms; a minimal section under a 1024-writer commitment sheds ~1025 decompressions for 2 verifies. /simplify, reuse: the KAT harness hand-rolled both the committed-pseudonym set (silently dropping the dedup pass, so it trial-verified a different candidate set than the gate) and the six-structure walk. Extracts for_each_structure as the single definition of what stage 3 authenticates and drives both the gate and the harness from it. /simplify, altitude: the cfg(feature = "test-kit") visibility split was hollow — four crates enable test-kit and Cargo unifies features graph-wide, so the symbol was already plain pub everywhere. Replaced with the house pattern, #[doc(hidden)] pub, as crates/core/src/kdf/mod.rs uses for the same reason. Comment discipline: the one-signer rationale was restated in five places; it now lives in blueprint/engine.md with cross-references from core.md, seal/section.rs and the gate. Drops the absence-justifying "Run stage 2 first" block, the forward-compat apologia in the blueprint, and the amplification maths re-argued in a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2 Entire-Checkpoint: 21bafcd6f524
558149e to
344af80
Compare
Problem
The adoption gate's stage 3 trial-verified each seed-bearing structure against every committed write-capable pseudonym, independently. #1050 capped both factors and made the scan resume where it last verified, which fixed the honest case — but left the adversarial product intact.
The resume hint is steerable. The scan walked forward with stride 1 from
resume_at, so an accepted contact publishes a scope root whose own owner-signed commitment names 1024 write pseudonyms they generated, then signs structure k under the pseudonym at index(resume_at - 1) mod n. Stages 1 and 2 both pass — the commitment genuinely is owner-signed by them — so stage 3 runs the full product with the hint saving nothing.There is a second, sharper consequence with no CPU argument at all: per-structure trial-verify adopted a structure splice. A structure lifted verbatim out of a different record at the same scope and epoch, authored by a different committed writer, recomputes an identical signed input —
scope,epoch,structTag,recipientTagandH(ciphertext)all match — so it verified and the record adopted.Measured on this branch before fixing, against a real worst-case section (1025 pseudonyms, 1284 structures),
--release:Attacker cost for the adversarial row is ~1284 signatures, ~50 ms. That is ~2000x wall-clock amplification native, re-incurred on every sequence bump, and worse in a WASM worker.
Fix — one section, one signer
A section is a single rotator's work.
rotation/reseal.rsbinds oneidentity.pseudonym_signerand signs every structure through one closure, re-signing at the record's read epoch even the history links it carries forward verbatim (reseal.rs:440-447); it additionally refuses to re-seal unless that signer iscommitment.owner_pseudonym_pk(ResealError::SignerNotCommitted). No producer in the repo — production, testkit, or fixture — has ever emitted a mixed-signer section.So the gate now pins the pseudonym that authenticated the section's first structure and requires every later structure to verify under that key alone. A section signed by two committed pseudonyms is a whole-record
structure-signature-invalidtrust violation — fail-closed, not softened to a warning or a retry.Pinning tightens the trust model rather than loosening it: it does not narrow who may sign a section (any committed write-capable pseudonym still can, at any index — pinned by
any_committed_pseudonym_can_pin_the_sections_signerand by an accept vector signed throughout by a non-owner committed pseudonym), only how many signers one section may have.Does any planned multi-writer shape need per-structure signers?
No. Checked
CONTEXT.mdand the blueprint corpus before committing to the tightening:CONTEXT.md"Structure signature" — "the rotator's detached pseudonym signature", singular.blueprint/engine.mdeager-set law — "The rotator detached-signs every seed-bearing structure it re-seals with its writer pseudonym (feat(05): Folder System - IPNS metadata, folder hierarchy, and operations #39 D2); a grantee re-wraps blobs for the committed tag set verbatim".blueprint/engine.mdgrants — "every rekey re-seals surviving committed grants uniformly in the republish it already does"; re-mint does not exist as a separate step.blueprint/core.md"Structure signatures" (feat(05): Folder System - IPNS metadata, folder hierarchy, and operations #39 D2/D3) — same singular framing.The commitment names many write pseudonyms so that any of several write-capable clients can be the rotator — not so several can co-sign one section. Multi-writer concurrency resolves on the publish plane (CAS, sequence), where each publish emits a whole section authored by exactly one party; a plain write carries the previous rotator's section forward verbatim, which stays single-signer. Today the constraint is in fact stronger than what this PR enforces:
reseal_scope_rootaccepts only the owner pseudonym, so the write-grantee branch ofcommitted_write_pseudonymsis not yet reachable by any producer.A shape that genuinely wanted per-structure signers would need a per-structure signer index on the wire — the gate cannot otherwise avoid the product — so it would be a format change, not a silent relaxation of this rule. That is the sentence now in
blueprint/engine.md.What landed
gate/adoption.rs—StructureAuthenticatorswaps itsresume_athint for a pinned verifier. Four unit tests: any committed pseudonym can pin; a second committed signer is rejected; a signature from no committed pseudonym is rejected pinned or not; a commitment naming no usable write pseudonym authenticates nothing.blueprint/engine.mdstates one section, one signer at the gate spec, with the splice as the integrity argument and the work bound as the availability one.blueprint/core.mdandcrates/core/src/seal/section.rsjustified the 1024/256 caps with "one signature per structure per committed pseudonym", which is no longer the shape; both now cross-reference the engine rule rather than restating it.blueprint/testing.mdrecords the engine's KAT vectors and the gate they block in.net/author.rs::check_scope_rootalready calls the sameauthenticate_section_structures, so symmetry holds by construction. Addeda_scope_root_envelope_whose_section_has_two_signers_is_refused, which returnsErrand so fires in a release build. Both signers there are committed and the commitment is re-signed by the anchored owner, so only the pin can reject it.crates/engine/kat/gate/, written only bycargo run -p cipherbox-engine --example kat_genand consumed bycrates/engine/tests/kat_gate.rs. Two accept, two reject, all sharing one commitment that names two write-capable pseudonyms. Vectors freeze whole scope-root head blocks; the harness runs stage 2 first and asserts it passes, then asserts each reject's every structure signature is individually valid under a committed pseudonym and that together they name two — so a reject is attributable to the pin by construction, not by the vector's name. The reject family carries the mixed-signer case and the structure splice; the splice is also the only vector exercising therecipientTagarm of the signed input. Both the generator (expect_err) and the suite fail against pre-fix code; confirmed by revertingadoption.rsand re-running. The generator is byte-deterministic (verified by diffing two runs) and uses its own key axis, disjoint from every in-crate fixture..prettierignore—crates/engine/kat/was missing next tocrates/core/kat/, so lint-staged would rewrite a generator-owned tree that CI byte-diffs. Pre-existing gap my new files would have tripped.Review gates
/security-review,/crypto-privacy-reviewand/simplifyall ran ongit diff main...HEAD. No CRITICAL or HIGH findings — the change is a strict narrowing of an accept predicate, andverify_strictrules out the duplicate-signature trick that would let an attacker steer which key gets pinned. Folded back in the second commit:committed_write_pseudonymsdecompressed up to 1025 Ed25519 points eagerly on every resolved record, though the pin means at most one is used: anO(pseudonyms)term the pin otherwise removes, and ~8-10% of a full verify each. Keys now stay compressed and decompress inside the trial loop; the pin holds the verifier rather than an index. Honest worst case 81.5 ms → 75.9 ms; a minimal section under a 1024-writer commitment sheds ~1025 decompressions to perform 2 verifies.for_each_structureas the single definition of what stage 3 authenticates; the gate and the harness both drive it, andauthenticate_section_structuresgot shorter.#[cfg(feature = "test-kit")]split I first used was hollow: four crates enabletest-kitand Cargo unifies features graph-wide, so the symbol was already plainpubeverywhere. Replaced with the house pattern,#[doc(hidden)] pub, ascrates/core/src/kdf/mod.rsuses for the same reason.blueprint/engine.mdwith cross-references. Dropped an absence-justifying "Run stage 2 first" block, forward-compat apologia in the blueprint, and the amplification maths re-argued in a test.Pushed back on one finding: the produce-side mirror belongs in
net/author.rs, notrotation/reseal.rs.reseal_scope_rootholds a single signer and signs through one closure, so a mixed-signer section is structurally unconstructable there — the check would guard an impossibility. The only place such a section can arise is a section assembled from parts it did not mint, i.e. carrying a previous record's section into a new envelope, andencode_envelopeappears exactly once in engine production code, behindcheck_scope_root.Deliberately out of scope
crates/corebehind aVerifiedGrantSetwitness. The altitude pass argued the layering is inverted: core's caps are now justified by an algorithm core cannot see, and the stage-2 precondition is prose where it could be a type. Real, but it touchescrates/coreprimitives,verify_grant_set's signature, the core KAT manifest and several engine call sites, and it is a blueprint amendment either way. Filed with the counter-argument recorded.kat_content.rs's manifest isdeny_unknown_fieldswith a 1:1 fixture table; consolidating means restructuring a file this PR does not own.reseal_scope_root— theGateVerified<_>compile-time obligation for carried history links, the oldest-first ordering proof, and the resource-cap verdict-class reconciliation innet/adopter.rs.Gates run locally
cargo fmt --all --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspacecargo check -p cipherbox-wasm --target wasm32-unknown-unknowncargo run -p cipherbox-engine --example kat_gen+git status --porcelain -- crates/engine/katcargo run -p cipherbox-core --example kat_gen+git diff -- crates/core/katpnpm lint:tracker-refskat_gate.rsblocks a merge from the day it lands:cargo test -p cipherbox-enginein the Engine Tests job, whose "Engine KAT vectors fresh" step already regenerates and diffs the wholecrates/engine/kattree.Closes #1102
Summary by CodeRabbit
Security Enhancements
Validation
Documentation
Note
Pin grant section's signer to a single committed pseudonym at adoption gate stage 3
StructureAuthenticatorin adoption.rs to pin the first committed pseudonym that successfully verifies a structure, then require all subsequent structures in the same section to verify under that same key.for_each_structurehelper to iterate all seed-bearing structures in aGrantSection, and exposesauthenticate_section_structures,committed_write_pseudonyms, andfor_each_structurepublicly for use by the KAT generator and tests.crates/engine/kat/gate/covering single-signer acceptance and mixed-signer/spliced-structure rejection cases.structure-signature-invalid; previously mixed-signer sections could pass.Macroscope summarized 344af80.