Skip to content

fix: pin the grant section's signer at the adoption gate's stage 3 - #1120

Merged
FSM1 merged 3 commits into
mainfrom
fix/pin-adoption-gate-section-signer
Aug 7, 2026
Merged

fix: pin the grant section's signer at the adoption gate's stage 3#1120
FSM1 merged 3 commits into
mainfrom
fix/pin-adoption-gate-section-signer

Conversation

@FSM1

@FSM1 FSM1 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

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, recipientTag and H(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:

case before after
adversarial, signers spread across the committed set 1,316,100 verifications, 74.6 s, ADOPTED 1,026 verifications, 35 ms, rejected
honest worst case, one signer at the last index 2,308 verifications, 80.6 ms, adopted 2,308 verifications, 75.9 ms, adopted

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.rs binds one identity.pseudonym_signer and 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 is commitment.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-invalid trust 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_signer and 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.md and the blueprint corpus before committing to the tightening:

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_root accepts only the owner pseudonym, so the write-grantee branch of committed_write_pseudonyms is 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.rsStructureAuthenticator swaps its resume_at hint 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, normativelyblueprint/engine.md states 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.md and crates/core/src/seal/section.rs justified 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.md records the engine's KAT vectors and the gate they block in.
  • Produce-side mirror (rule 8) — verified rather than assumed: net/author.rs::check_scope_root already calls the same authenticate_section_structures, so symmetry holds by construction. Added a_scope_root_envelope_whose_section_has_two_signers_is_refused, which returns Err and 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.
  • KAT vectors — a self-contained engine KAT set under crates/engine/kat/gate/, written only by cargo run -p cipherbox-engine --example kat_gen and consumed by crates/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 the recipientTag arm of the signed input. Both the generator (expect_err) and the suite fail against pre-fix code; confirmed by reverting adoption.rs and re-running. The generator is byte-deterministic (verified by diffing two runs) and uses its own key axis, disjoint from every in-crate fixture.
  • .prettierignorecrates/engine/kat/ was missing next to crates/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-review and /simplify all ran on git diff main...HEAD. No CRITICAL or HIGH findings — the change is a strict narrowing of an accept predicate, and verify_strict rules out the duplicate-signature trick that would let an attacker steer which key gets pinned. Folded back in the second commit:

  • 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. Both closed, plus the splice vector.
  • Efficiencycommitted_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, 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.
  • 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, which existed in three places. Extracted for_each_structure as the single definition of what stage 3 authenticates; the gate and the harness both drive it, and authenticate_section_structures got shorter.
  • Visibility — the #[cfg(feature = "test-kit")] split I first used 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 had reached five sites; it now lives in blueprint/engine.md with 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, not rotation/reseal.rs. reseal_scope_root holds 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, and encode_envelope appears exactly once in engine production code, behind check_scope_root.

Deliberately out of scope

Gates run locally

gate exit
cargo fmt --all --check 0
cargo clippy --workspace --all-targets -- -D warnings 0
cargo test --workspace 0
cargo check -p cipherbox-wasm --target wasm32-unknown-unknown 0
cargo run -p cipherbox-engine --example kat_gen + git status --porcelain -- crates/engine/kat 0, clean
cargo run -p cipherbox-core --example kat_gen + git diff -- crates/core/kat 0, clean
pnpm lint:tracker-refs 0
markdownlint + prettier on the changed markdown 0

kat_gate.rs blocks a merge from the day it lands: cargo test -p cipherbox-engine in the Engine Tests job, whose "Engine KAT vectors fresh" step already regenerates and diffs the whole crates/engine/kat tree.

Closes #1102

Summary by CodeRabbit

  • Security Enhancements

    • Adoption checks now require all structures in a grant section to be signed by the same authorized, write-capable signer.
    • Mixed-signer sections and spliced structures are rejected as invalid.
    • Invalid owner write data continues to reject the record when present.
  • Validation

    • Added comprehensive acceptance and rejection coverage for signer consistency, structure authentication, and content-DAG processing.
    • Added deterministic test fixtures to verify adoption-gate behavior and error reporting.
  • Documentation

    • Clarified duplicate-detection behavior and documented updated performance limits.

Note

Pin grant section's signer to a single committed pseudonym at adoption gate stage 3

  • Rewrites StructureAuthenticator in 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.
  • Adds for_each_structure helper to iterate all seed-bearing structures in a GrantSection, and exposes authenticate_section_structures, committed_write_pseudonyms, and for_each_structure publicly for use by the KAT generator and tests.
  • Generates new adoption-gate KAT vectors (accept and reject) in crates/engine/kat/gate/ covering single-signer acceptance and mixed-signer/spliced-structure rejection cases.
  • Adds an integration test suite in kat_gate.rs that validates stage-3 vectors against the pinned-signer predicate.
  • Behavioral Change: sections signed by more than one committed pseudonym now fail with structure-signature-invalid; previously mixed-signer sections could pass.

Macroscope summarized 344af80.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The 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.

Changes

Adoption gate signer enforcement

Layer / File(s) Summary
Signer invariant and test contract
blueprint/core.md, blueprint/engine.md, blueprint/testing.md, crates/core/src/seal/section.rs
The specifications define one-section-one-signer authentication, bounded verification work, producer validation, and engine KAT coverage.
Pinned stage-3 authentication
crates/engine/src/gate/adoption.rs, crates/engine/src/gate/mod.rs
Stage-3 authentication pins the first valid committed pseudonym and requires every structure to use that signer. Structure traversal is shared through public helpers.
Producer validation and vector generation
crates/engine/src/net/author.rs, crates/engine/examples/kat_gen.rs
Authoring rejects mixed-signer scope-root sections. The generator creates and validates deterministic acceptance, mixed-signer, and spliced-structure vectors.
KAT fixtures and verification
crates/engine/kat/gate/*, crates/engine/tests/kat_gate.rs, .prettierignore
Manifests and vectors cover valid and rejected signer patterns. Tests validate commitments, signer uniqueness, rejection metadata, and fixture integrity.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • FSM1/cipher-box#1049 — Introduced the stage-3 structure-authentication logic extended by this change.
  • FSM1/cipher-box#1058 — Added related producer-side scope-root structure-signature validation.
  • FSM1/cipher-box#1098 — Modified the same adoption-gate authentication and committed-pseudonym handling.

Suggested labels: v2-build, comp:engine

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #1102 by pinning signers, updating specifications, adding producer validation, and covering mixed-signer rejection with KATs.
Out of Scope Changes check ✅ Passed The implementation, documentation, producer validation, shared traversal, and KAT coverage all support the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: pinning the grant section signer during adoption gate stage 3.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pin-adoption-gate-section-signer

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@FSM1
FSM1 force-pushed the fix/pin-adoption-gate-section-signer branch from 476358f to 491f14c Compare August 6, 2026 15:50
@FSM1
FSM1 marked this pull request as ready for review August 6, 2026 21:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 77ad6a7 and 491f14c.

📒 Files selected for processing (13)
  • .prettierignore
  • blueprint/core.md
  • blueprint/engine.md
  • blueprint/testing.md
  • crates/core/src/seal/section.rs
  • crates/engine/examples/kat_gen.rs
  • crates/engine/kat/gate/manifest.json
  • crates/engine/kat/gate/vectors/section_signer_accept.json
  • crates/engine/kat/gate/vectors/section_signer_reject.json
  • crates/engine/src/gate/adoption.rs
  • crates/engine/src/gate/mod.rs
  • crates/engine/src/net/author.rs
  • crates/engine/tests/kat_gate.rs

Comment thread crates/engine/examples/kat_gen.rs Outdated
@FSM1
FSM1 marked this pull request as draft August 6, 2026 21:04
@FSM1
FSM1 marked this pull request as ready for review August 6, 2026 21:11
@FSM1
FSM1 marked this pull request as draft August 7, 2026 00:07
@FSM1 FSM1 closed this Aug 7, 2026
@FSM1 FSM1 reopened this Aug 7, 2026
FSM1 and others added 3 commits August 7, 2026 09:14
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
@FSM1
FSM1 force-pushed the fix/pin-adoption-gate-section-signer branch from 558149e to 344af80 Compare August 7, 2026 07:16
@FSM1
FSM1 marked this pull request as ready for review August 7, 2026 07:24
@FSM1
FSM1 merged commit d2a75db into main Aug 7, 2026
33 checks passed
@FSM1
FSM1 deleted the fix/pin-adoption-gate-section-signer branch August 7, 2026 07:34
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.

engine: close the residual stage-3 trial-verify amplification by pinning the section's signer

1 participant