From 97f68748242bee9af4a3e129ecff47002696e98b Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 6 Aug 2026 14:18:57 +0200 Subject: [PATCH 1/3] fix: pin the grant section's signer at the adoption gate's stage 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .prettierignore | 8 +- blueprint/core.md | 9 +- blueprint/engine.md | 29 ++- blueprint/testing.md | 8 + crates/engine/examples/kat_gen.rs | 169 +++++++++++++++- crates/engine/kat/gate/manifest.json | 15 ++ .../gate/vectors/section_signer_accept.json | 7 + .../gate/vectors/section_signer_reject.json | 9 + crates/engine/src/gate/adoption.rs | 165 ++++++++++------ crates/engine/src/gate/mod.rs | 2 +- crates/engine/src/net/author.rs | 45 ++++- crates/engine/tests/kat_gate.rs | 186 ++++++++++++++++++ 12 files changed, 575 insertions(+), 77 deletions(-) create mode 100644 crates/engine/kat/gate/manifest.json create mode 100644 crates/engine/kat/gate/vectors/section_signer_accept.json create mode 100644 crates/engine/kat/gate/vectors/section_signer_reject.json create mode 100644 crates/engine/tests/kat_gate.rs diff --git a/.prettierignore b/.prettierignore index 970f36ec1..c7bf5be0a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,10 +18,12 @@ # Macroscope, not by us; prettier rewrites its double-quoted YAML scalars .macroscope/ -# KAT manifest and vector fixtures - written only by the committed generator -# (cargo run -p cipherbox-core --example kat_gen); the CI freshness gate -# diffs them byte-for-byte against generator output +# KAT manifests and vector fixtures - written only by the committed generators +# (cargo run -p cipherbox-{core,engine} --example kat_gen); the CI freshness +# gates diff them byte-for-byte against generator output, so a prettier rewrite +# would fail the gate it cannot fix crates/core/kat/ +crates/engine/kat/ # Generated OpenAPI docs artifact - byte-stability is owned by the generator # (CI openapi-freshness diffs a regeneration against the committed file) diff --git a/blueprint/core.md b/blueprint/core.md index 975c7ba42..0a51dc67c 100644 --- a/blueprint/core.md +++ b/blueprint/core.md @@ -137,10 +137,11 @@ ownerPseudonymPk, [(tag, permission, pseudonymPk)]}`), owner blob, the optional fail-closed at decode and encode — `historyLinks` at 256, `grantBlobs` and the commitment's `entries` both at 1024 (`too-many-structures`) — and two history links may not carry - equal sealed bytes (`duplicate-history-link`): the gate verifies one signature - per structure per committed pseudonym, so an unbounded collection on **either** - side of that product is a reader-CPU amplifier, and each epoch mints one link - under a fresh nonce, so a repeat is an authored anomaly. The two 1024 ceilings + equal sealed bytes (`duplicate-history-link`): the gate resolves the section's + single signer by trial-verifying its first structure against the committed + pseudonyms, so an unbounded collection on **either** side of that + `pseudonyms + structures` sum is a reader-CPU amplifier, and each epoch mints + one link under a fresh nonce, so a repeat is an authored anomaly. The two 1024 ceilings are one number: the ledger must match the committed set exactly and a re-seal wraps one blob per ledger row, so a commitment past the ceiling could only mint a section its own encoder refuses. `historyLinks` is ordered **oldest epoch diff --git a/blueprint/engine.md b/blueprint/engine.md index d33532f84..f53800106 100644 --- a/blueprint/engine.md +++ b/blueprint/engine.md @@ -163,18 +163,33 @@ the #33 pipeline with the #39 D3 seal-auth stage and the D4 floor law: against the contact-code-anchored owner identity (#34 D6, #39 D1). 3. **Grant-section authentication** (scope roots) — every seed-bearing structure (grant blobs, owner blob, the optional owner-write-blob, ascent - link, history links, write-body) verifies under a committed write-capable - pseudonym via core's pure per-structure checks; any failure rejects the - **whole record** as a trust violation (#39 D3). The owner-write-blob is - optional on the wire, but a **present** one with a missing or invalid - structure signature is a whole-record trust violation, never staleness (its - signature is recomputed at the authenticated envelope epoch like every other - structure, though its sealed AAD binds the write epoch). + link, history links, write-body) verifies under **one** committed + write-capable pseudonym via core's pure per-structure checks; any failure + rejects the **whole record** as a trust violation (#39 D3). The + owner-write-blob is optional on the wire, but a **present** one with a + missing or invalid structure signature is a whole-record trust violation, + never staleness (its signature is recomputed at the authenticated envelope + epoch like every other structure, though its sealed AAD binds the write + epoch). 4. **Sequence** — strictly newer than the durable per-name floor. 5. **Epoch** — epoch tag at or above the scope's durable epoch floor. 6. **Unseal** — success required; core's trust-violation error class carries through fail-closed. +**One section, one signer** (stage 3). A section is a single rotator's work: it +re-seals and detached-signs every structure with its own writer pseudonym, +re-signing at the record's read epoch even the history links it carries forward +verbatim (`rotation/reseal.rs`). The gate therefore **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 +unadoptable, not merely unusual. Pinning is what bounds stage 3's work at +`pseudonyms + structures` rather than their product — without it an accepted +contact commits 1024 write pseudonyms of their own and spreads a section's +signatures across them, buying ~1000x reader-CPU amplification for ~1284 +signatures. The produce side runs the same predicate release-active +(`net/author.rs::check_scope_root`), so this build never signs a section its own +gate rejects. + A gate failure is never mere staleness: the engine pins last-known-good, raises the withheld-update escalation where applicable, and never renders the rejected record. Duplicate `id`s and duplicate `ipnsName`s within a scope diff --git a/blueprint/testing.md b/blueprint/testing.md index 2fc6bcd88..6cdcbf9be 100644 --- a/blueprint/testing.md +++ b/blueprint/testing.md @@ -116,6 +116,14 @@ Adversarial cases are first-class: the harness can replay, transplant, and re-sign records with any key it holds; every crypto-review finding (#35) gets a pinned regression scenario. +The engine also ships **its own KAT sets**, under core's regime but for the +formats and predicates core cannot reach: `kat/` freezes the content-DAG root, +`kat/gate/` freezes the adoption gate's stage-3 verdict over whole scope-root +head blocks — including the **one section, one signer** reject. Both are +written only by `cargo run -p cipherbox-engine --example kat_gen`, and the +**Engine Tests** gate regenerates the tree and diffs it before running the +suites, so a verdict change that is not a deliberate re-freeze fails there. + ### The contract suite — the live API gate The sdk-e2e descendant (#28 D6), and it inherits sdk-e2e's most valuable v1 diff --git a/crates/engine/examples/kat_gen.rs b/crates/engine/examples/kat_gen.rs index 72c7167d1..08d78db2a 100644 --- a/crates/engine/examples/kat_gen.rs +++ b/crates/engine/examples/kat_gen.rs @@ -1,6 +1,6 @@ -//! The committed KAT generator for the engine's content-DAG fixtures -//! (blueprint/core.md "KAT regime": vectors regenerate only through committed -//! generators, never hand-edits). Sibling to core's generator; see +//! The committed KAT generator for the engine's content-DAG and adoption-gate +//! fixtures (blueprint/core.md "KAT regime": vectors regenerate only through +//! committed generators, never hand-edits). Sibling to core's generator; see //! `crates/engine/tests/kat_content.rs` for why the engine needs its own. //! //! Run from any cwd: @@ -21,15 +21,26 @@ use std::path::Path; use cipherbox_core::codec::{Map, Value, encode}; use cipherbox_core::content::{CONTENT_CID_CODEC, compute_cid, encode_content_cid_str, verify_cid}; +use cipherbox_core::error::TrustViolation; +use cipherbox_core::kdf; +use cipherbox_core::seal::{ + GrantSetEntry, Permission, STRUCT_TAG_WRITE_BODY, StructureSigInput, encode_envelope, + encode_grant_section, set_grant_section, sign_grant_set, sign_structure, +}; use cipherbox_core::suite::aead::KEY_LEN; +use cipherbox_core::suite::ecdsa::EcdsaSigner; +use cipherbox_core::suite::ed25519::Ed25519Signer; use cipherbox_engine::content::{ ContentKey, ContentProfile, DAG_ROOT_CODEC, DagError, ROOT_FORMAT_VERSION, assemble, decode_root, frame_and_seal, }; use cipherbox_engine::entropy::{Entropy, EntropyError}; +use cipherbox_engine::gate::authenticate_section_structures; +use cipherbox_engine::testkit::{OWNER_ROOT_EPOCH, OwnerRootSpec, owner_root_fixture}; use serde::Serialize; const PROFILE: &str = "cipherbox/v2 engine content-dag"; +const GATE_PROFILE: &str = "cipherbox/v2 engine adoption-gate"; /// A pinned entropy stream: KAT vectors must be byte-reproducible, so the /// generator injects a fixed nonce sequence instead of sampling one. @@ -95,6 +106,22 @@ struct DagCapacityRejectVector { class: String, } +/// A scope-root head block the gate's stage 3 must accept or refuse, with the +/// owner identity that anchors stage 2. The block carries its grant section +/// under `grantSection`, exactly as it arrives off the record plane. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SectionSignerVector { + name: String, + head_block: String, + owner_identity_pk: String, + /// Absent on an accept vector. + #[serde(skip_serializing_if = "Option::is_none")] + check: Option, + #[serde(skip_serializing_if = "Option::is_none")] + class: Option, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct FileCount { @@ -130,6 +157,15 @@ struct Manifest { content: ContentSection, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GateManifest { + manifest_version: u64, + profile: String, + section_signer_accept: FileCount, + section_signer_reject: RejectSection, +} + fn main() { let kat_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("kat"); let content_dir = kat_dir.join("vectors").join("content"); @@ -184,10 +220,45 @@ fn main() { }; write_pretty(&kat_dir.join("manifest.json"), &manifest); + let gate_dir = kat_dir.join("gate"); + let gate_vectors = gate_dir.join("vectors"); + fs::create_dir_all(&gate_vectors) + .unwrap_or_else(|e| panic!("create {}: {e}", gate_vectors.display())); + let (signer_accept, signer_reject) = build_section_signer_vectors(); + write_pretty( + &gate_vectors.join("section_signer_accept.json"), + &signer_accept, + ); + write_pretty( + &gate_vectors.join("section_signer_reject.json"), + &signer_reject, + ); + write_pretty( + &gate_dir.join("manifest.json"), + &GateManifest { + manifest_version: 1, + profile: GATE_PROFILE.to_string(), + section_signer_accept: FileCount { + file: "vectors/section_signer_accept.json".to_string(), + count: signer_accept.len(), + }, + section_signer_reject: RejectSection { + file: "vectors/section_signer_reject.json".to_string(), + count: signer_reject.len(), + checks: trust_checks_in_surface_order( + signer_reject.iter().map(|v| v.check.as_deref().unwrap()), + ), + }, + }, + ); + println!( - "kat_gen: wrote {} accept, {} reject, 2 capacity vectors + manifest.json", + "kat_gen: wrote {} accept, {} reject, 2 capacity vectors + manifest.json; \ + gate: {} accept, {} reject + gate/manifest.json", root_accept.len(), - root_reject.len() + root_reject.len(), + signer_accept.len(), + signer_reject.len() ); } @@ -214,6 +285,94 @@ fn checks_in_surface_order<'a>(present: impl IntoIterator) -> Ve checks } +/// The distinct checks in core's trust-surface order, asserting each comes from +/// [`TrustViolation::CHECKS`] — the gate invents no cryptographic error code of +/// its own, so a gate reject vector can only name a core trust verdict. +fn trust_checks_in_surface_order<'a>(present: impl IntoIterator) -> Vec { + let present: BTreeSet<&str> = present.into_iter().collect(); + let checks: Vec = TrustViolation::CHECKS + .iter() + .filter(|c| present.contains(*c)) + .map(|c| (*c).to_string()) + .collect(); + assert_eq!( + checks.len(), + present.len(), + "every gate reject-vector check must come from core's trust surface" + ); + checks +} + +/// Stage 3's **one section, one signer** rule frozen as vectors +/// (blueprint/engine.md "Adoption gate and floors"): a real owner root, and the +/// same root with one structure re-signed under a *second committed* write +/// pseudonym. Both pass stage 2 — the reject is stage 3's alone. +fn build_section_signer_vectors() -> (Vec, Vec) { + let owner_identity = EcdsaSigner::from_scalar(&[0x11; 32]).expect("valid scalar"); + let owner_enc = kdf::enc_subkey(&[0x33; 32]).public(); + let fixture = owner_root_fixture(OwnerRootSpec { + owner_identity: &owner_identity, + owner_enc: &owner_enc, + scope_id: [2u8; 16], + root_id: [1u8; 16], + children: Vec::new(), + child_scope_index: Vec::new(), + parent_node_seed: None, + owner_write_blob_epoch: Some(OWNER_ROOT_EPOCH), + }); + let owner_identity_pk = hex::encode(owner_identity.verifying_key().to_sec1()); + + // The attacker's own committed write pseudonym: a section that switches to + // it part-way is what stage 3 used to trial-verify against the whole set. + let second = Ed25519Signer::from_seed([0x55; 32]); + let mut section = fixture.grant_section.clone(); + section.commitment.entries.push(GrantSetEntry::new( + [0x66; 32], + Permission::Write, + second.verifying_key().to_bytes(), + )); + let input = StructureSigInput::over_ciphertext( + [2u8; 16], + OWNER_ROOT_EPOCH, + STRUCT_TAG_WRITE_BODY, + None, + §ion.write_body.sealed, + ); + section.write_body.signature = sign_structure(&second, &input).to_bytes(); + section.commitment_sig = sign_grant_set(&owner_identity, §ion.commitment) + .expect("commitment signs") + .to_compact(); + + let mut envelope = fixture.envelope.clone(); + set_grant_section( + &mut envelope, + encode_grant_section(§ion).expect("section encodes"), + ); + let mixed_block = encode_envelope(&envelope).expect("envelope encodes"); + + authenticate_section_structures(&fixture.grant_section, &fixture.envelope) + .expect("a single-signer section authenticates"); + let error = authenticate_section_structures(§ion, &envelope) + .expect_err("a two-signer section must fail closed"); + + ( + vec![SectionSignerVector { + name: "single-signer-section".to_string(), + head_block: hex::encode(&fixture.head_block), + owner_identity_pk: owner_identity_pk.clone(), + check: None, + class: None, + }], + vec![SectionSignerVector { + name: "two-committed-signers".to_string(), + head_block: hex::encode(&mixed_block), + owner_identity_pk, + check: Some(error.check().to_string()), + class: Some(error.class().to_string()), + }], + ) +} + /// Deterministic plaintext of `len` bytes. fn plaintext(len: usize) -> Vec { (0..len).map(|i| (i % 251) as u8).collect() diff --git a/crates/engine/kat/gate/manifest.json b/crates/engine/kat/gate/manifest.json new file mode 100644 index 000000000..3a4fe7356 --- /dev/null +++ b/crates/engine/kat/gate/manifest.json @@ -0,0 +1,15 @@ +{ + "manifestVersion": 1, + "profile": "cipherbox/v2 engine adoption-gate", + "sectionSignerAccept": { + "file": "vectors/section_signer_accept.json", + "count": 1 + }, + "sectionSignerReject": { + "file": "vectors/section_signer_reject.json", + "count": 1, + "checks": [ + "structure-signature-invalid" + ] + } +} diff --git a/crates/engine/kat/gate/vectors/section_signer_accept.json b/crates/engine/kat/gate/vectors/section_signer_accept.json new file mode 100644 index 000000000..dafb316e1 --- /dev/null +++ b/crates/engine/kat/gate/vectors/section_signer_accept.json @@ -0,0 +1,7 @@ +[ + { + "name": "single-signer-section", + "headBlock": "a561760162696450010101010101010101010101010101016865706f6368546167a26565706f6368016573636f706550020202020202020202020202020202026a726561645365616c656458560b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b521da1f96fce302b1a11af940c05829dd9ec63203d88e3dd3085e4f7bf234af14d1fbeed5b60080ee0d823b7f2c2c195c1926d750df600f574242b1b7f646c6772616e7453656374696f6e590359a7696f776e6572426c6f62a363656e6358205dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef22637369675840e7dd1d97334aa51a1a3073301155dd8f1b297e3416edf1168eaa9dbaf030a83a9a6d79a6802d945eb1ce7be958b4d3df846befa18faa358ddb42d1f2d1f6b0006a6369706865727465787458472dd0309ab5944853162209866edc6e701a6a398ca00c64ef5ebf50c01e6aae10b25c448cda2249379163bab060516a269ac6a4d603fda3e2a84f919c8ce04eeff97deac4bd64e7697772697465426f6479a2637369675840c43925fcfb040376c20c7ad0cf4d3c3b908779dc08650660fcec66fc5246c9ae9c1394ebaa0585c63d8bb4ff4156870873073457d95b525e440ee9f50ee8ab0c667365616c6564585f16161616161616161616161616161616161616161616161661273f65ddf07b48211e3ee0e9946c770bc381cba4fd144453a3879999dcf8c5fbe64261460d4dd9bed5a3ed551f1bc316182d03f2bd029db126b14de066dff615f3d3d46b1a956a636f6d6d69746d656e745886a367656e7472696573806869706e734e616d65583e6b3531717a693575717535646b656b7970627475786e6c333237657478676b6c386c6732733076776264346f7931303469346774656f7864773179756238706f776e657250736575646f6e796d506b5820a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f06a6772616e74426c6f6273806c686973746f72794c696e6b73806d636f6d6d69746d656e745369675840b837d7ffba80f519c1594e66609291ab24d087907609848966ec6ed196a1c627173de86a956b1b4a344c4d8a6bba3533042dc797f05ef6afa71e707939ba41b56e6f776e65725772697465426c6f62a363656e635820ac01b2209e86354fb853237b5de0f4fab13c7fcbf433a61c019369617fecf10b6373696758406e2e4ffd34fbd59993ffae386fc8daa2b5d3f655712392f231e34a8a9672d62e57e9f5b833fe33cdf76ecc7946e559572092dd14572821363cebedf2f87092076a63697068657274657874584e14f26b8d7c113806320bb72398d11be5a4fdb095fce40b39d783ce97002c65ab2de18579ebf475a6effcd0954a9e95a9162d58e913007223041ce4402a5d139019a457c211069a4d74875e9eeb2c", + "ownerIdentityPk": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + } +] diff --git a/crates/engine/kat/gate/vectors/section_signer_reject.json b/crates/engine/kat/gate/vectors/section_signer_reject.json new file mode 100644 index 000000000..b30964303 --- /dev/null +++ b/crates/engine/kat/gate/vectors/section_signer_reject.json @@ -0,0 +1,9 @@ +[ + { + "name": "two-committed-signers", + "headBlock": "a561760162696450010101010101010101010101010101016865706f6368546167a26565706f6368016573636f706550020202020202020202020202020202026a726561645365616c656458560b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b521da1f96fce302b1a11af940c05829dd9ec63203d88e3dd3085e4f7bf234af14d1fbeed5b60080ee0d823b7f2c2c195c1926d750df600f574242b1b7f646c6772616e7453656374696f6e5903bfa7696f776e6572426c6f62a363656e6358205dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef22637369675840e7dd1d97334aa51a1a3073301155dd8f1b297e3416edf1168eaa9dbaf030a83a9a6d79a6802d945eb1ce7be958b4d3df846befa18faa358ddb42d1f2d1f6b0006a6369706865727465787458472dd0309ab5944853162209866edc6e701a6a398ca00c64ef5ebf50c01e6aae10b25c448cda2249379163bab060516a269ac6a4d603fda3e2a84f919c8ce04eeff97deac4bd64e7697772697465426f6479a2637369675840998cc940d34430978b41196e9540935471e703b73d81b1964a500d5998febcc97ae86595860b9fdb7a83de8736ce0496b711732231fd3a6d872c5ba852a1cc01667365616c6564585f16161616161616161616161616161616161616161616161661273f65ddf07b48211e3ee0e9946c770bc381cba4fd144453a3879999dcf8c5fbe64261460d4dd9bed5a3ed551f1bc316182d03f2bd029db126b14de066dff615f3d3d46b1a956a636f6d6d69746d656e7458eca367656e747269657381a363746167582066666666666666666666666666666666666666666666666666666666666666666a7065726d697373696f6e6577726974656b70736575646f6e796d506b5820c6822637c7d310ec57627be00ba259d253749f4aaf644470cffbe53a35f732426869706e734e616d65583e6b3531717a693575717535646b656b7970627475786e6c333237657478676b6c386c6732733076776264346f7931303469346774656f7864773179756238706f776e657250736575646f6e796d506b5820a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f06a6772616e74426c6f6273806c686973746f72794c696e6b73806d636f6d6d69746d656e7453696758401b9525b6bc42c212304fef05e9e649aaf0abcc6730dff236f9a33006f51965ae73429e22dfbc82c05ee263938fec2c979988e2b8cf30d8e4bd59b77b41285f3f6e6f776e65725772697465426c6f62a363656e635820ac01b2209e86354fb853237b5de0f4fab13c7fcbf433a61c019369617fecf10b6373696758406e2e4ffd34fbd59993ffae386fc8daa2b5d3f655712392f231e34a8a9672d62e57e9f5b833fe33cdf76ecc7946e559572092dd14572821363cebedf2f87092076a63697068657274657874584e14f26b8d7c113806320bb72398d11be5a4fdb095fce40b39d783ce97002c65ab2de18579ebf475a6effcd0954a9e95a9162d58e913007223041ce4402a5d139019a457c211069a4d74875e9eeb2c", + "ownerIdentityPk": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa", + "check": "structure-signature-invalid", + "class": "trust" + } +] diff --git a/crates/engine/src/gate/adoption.rs b/crates/engine/src/gate/adoption.rs index 162c9a1b3..5044ca1e4 100644 --- a/crates/engine/src/gate/adoption.rs +++ b/crates/engine/src/gate/adoption.rs @@ -327,13 +327,18 @@ fn committed_write_pseudonyms(commitment: &GrantSetCommitment) -> Vec, - resume_at: usize, + pinned: Option, } impl StructureAuthenticator { @@ -343,10 +348,13 @@ impl StructureAuthenticator { /// and `scope`/`epoch` come from the authenticated envelope — never a /// caller-supplied [`StructureSigInput`]. A signature therefore proves "the /// committed writer signed *these* bytes at *this* scope/epoch", not merely - /// "the writer once signed some hash". The structure is trusted iff the - /// recomputed input verifies under at least one committed pseudonym; - /// otherwise the whole record is a `structure-signature-invalid` trust - /// violation. + /// "the writer once signed some hash". + /// + /// The first structure is trusted iff the recomputed input verifies under at + /// least one committed pseudonym, which pins that pseudonym as the section's + /// signer; every later structure must verify under **that** key alone. Any + /// other outcome is a `structure-signature-invalid` trust violation over the + /// whole record. fn authenticate( &mut self, scope: [u8; 16], @@ -359,11 +367,12 @@ impl StructureAuthenticator { let input = StructureSigInput::over_ciphertext(scope, epoch, struct_tag, recipient_tag, ciphertext); let sig = Ed25519Signature::from_bytes(*signature); - let n = self.committed.len(); - for step in 0..n { - let i = (self.resume_at + step) % n; - if verify_structure(&self.committed[i], &input, &sig).is_ok() { - self.resume_at = i; + if let Some(i) = self.pinned { + return verify_structure(&self.committed[i], &input, &sig); + } + for (i, pseudonym) in self.committed.iter().enumerate() { + if verify_structure(pseudonym, &input, &sig).is_ok() { + self.pinned = Some(i); return Ok(()); } } @@ -372,9 +381,10 @@ impl StructureAuthenticator { } /// The gate's stage-3 predicate: authenticate every structure signature -/// `section` carries against the pseudonyms its own commitment names, +/// `section` carries against **one** of the pseudonyms its own commitment names, /// recomputed at `envelope`'s scope and epoch — whatever epoch a structure's own -/// sealed AAD binds (blueprint/core.md "Structure signatures"). +/// sealed AAD binds (blueprint/core.md "Structure signatures"). The single +/// signer is pinned by the first structure ([`StructureAuthenticator`]). /// /// Also run release-active on the produce side (`net/author.rs`), so a scope /// root this build's own gate would reject is never signed (AGENTS.md rule 8). @@ -385,7 +395,7 @@ pub fn authenticate_section_structures( let (scope, epoch) = (envelope.scope, envelope.epoch); let mut auth = StructureAuthenticator { committed: committed_write_pseudonyms(§ion.commitment), - resume_at: 0, + pinned: None, }; let mut authenticate = |tag: u8, recipient: Option<[u8; 32]>, ct: &[u8], sig: &[u8; 64]| { auth.authenticate(scope, epoch, tag, recipient, ct, sig) @@ -738,54 +748,97 @@ mod tests { assert_eq!(FLOOR_VERDICTS, &["sequence-not-newer", "epoch-below-floor"]); } - #[test] - fn the_resume_hint_never_widens_or_narrows_who_authenticates() { - // The scan resumes where it last verified, so it must still accept a - // signature from any committed pseudonym — whatever its index — and - // still reject one from none of them. - let signers: Vec = (0u8..4) + const SCOPE: [u8; 16] = [0x11; 16]; + const EPOCH: u64 = 7; + + fn committed_signers() -> Vec { + (0u8..4) .map(|i| Ed25519Signer::from_seed([i; 32])) - .collect(); - let mut auth = StructureAuthenticator { + .collect() + } + + fn authenticator(signers: &[Ed25519Signer]) -> StructureAuthenticator { + StructureAuthenticator { committed: signers.iter().map(|s| s.verifying_key()).collect(), - resume_at: 0, - }; + pinned: None, + } + } + + /// Sign `ciphertext` as an owner blob at the fixture scope/epoch. + fn signed(signer: &Ed25519Signer, ciphertext: &[u8]) -> [u8; 64] { let input = StructureSigInput::over_ciphertext( - [0x11; 16], - 7, + SCOPE, + EPOCH, STRUCT_TAG_OWNER_BLOB, None, - b"ciphertext", + ciphertext, ); - // Walk the committed set backwards, so every call starts from a resume - // index that is not the answer. - for signer in signers.iter().rev() { - let sig = sign_structure(signer, &input).to_bytes(); - auth.authenticate( - [0x11; 16], - 7, - STRUCT_TAG_OWNER_BLOB, - None, - b"ciphertext", - &sig, - ) - .expect("a committed pseudonym authenticates from any resume point"); + sign_structure(signer, &input).to_bytes() + } + + fn authenticate( + auth: &mut StructureAuthenticator, + ciphertext: &[u8], + signature: &[u8; 64], + ) -> Result<(), CodecError> { + auth.authenticate( + SCOPE, + EPOCH, + STRUCT_TAG_OWNER_BLOB, + None, + ciphertext, + signature, + ) + } + + #[test] + fn any_committed_pseudonym_can_pin_the_sections_signer() { + // Pinning must not narrow *who* may sign a section — only how many + // signers one section may have. Every committed pseudonym, at whatever + // index, still authenticates a section of its own. + let signers = committed_signers(); + for signer in &signers { + let mut auth = authenticator(&signers); + for structure in [&b"first"[..], b"second", b"third"] { + authenticate(&mut auth, structure, &signed(signer, structure)) + .expect("one committed pseudonym signs the whole section"); + } } + } - let outsider = Ed25519Signer::from_seed([0x99; 32]); - let sig = sign_structure(&outsider, &input).to_bytes(); + #[test] + fn a_section_signed_by_two_committed_pseudonyms_is_unadoptable() { + // The residual amplification: before pinning, every structure was + // trial-verified against the whole committed set, so an attacker who + // spread a section's signatures across many committed pseudonyms forced + // the full `pseudonyms x structures` product. A section is one rotator's + // work, so the second signer is a trust violation. + let signers = committed_signers(); + let mut auth = authenticator(&signers); + authenticate(&mut auth, b"first", &signed(&signers[0], b"first")).expect("pins signer 0"); assert_eq!( - auth.authenticate( - [0x11; 16], - 7, - STRUCT_TAG_OWNER_BLOB, - None, - b"ciphertext", - &sig - ) - .unwrap_err() - .check(), - "structure-signature-invalid" + authenticate(&mut auth, b"second", &signed(&signers[1], b"second")) + .unwrap_err() + .check(), + "structure-signature-invalid", + "a second committed signer must not authenticate the same section" ); } + + #[test] + fn a_signature_from_no_committed_pseudonym_is_rejected_pinned_or_not() { + let signers = committed_signers(); + let outsider = Ed25519Signer::from_seed([0x99; 32]); + let mut fresh = authenticator(&signers); + let mut pinned = authenticator(&signers); + authenticate(&mut pinned, b"first", &signed(&signers[2], b"first")).expect("pins signer 2"); + for auth in [&mut fresh, &mut pinned] { + assert_eq!( + authenticate(auth, b"forged", &signed(&outsider, b"forged")) + .unwrap_err() + .check(), + "structure-signature-invalid" + ); + } + } } diff --git a/crates/engine/src/gate/mod.rs b/crates/engine/src/gate/mod.rs index 99de6f3bb..58359ed37 100644 --- a/crates/engine/src/gate/mod.rs +++ b/crates/engine/src/gate/mod.rs @@ -17,8 +17,8 @@ pub mod floor; mod adoption; -pub(crate) use adoption::authenticate_section_structures; pub use adoption::{ Adopted, Candidate, FLOOR_VERDICTS, GateError, GateRejection, GateStage, PendingAdoption, ReaderContext, RejectionReason, SeedBlob, adopt, adopt_deferred, + authenticate_section_structures, }; diff --git a/crates/engine/src/net/author.rs b/crates/engine/src/net/author.rs index 5e441aff7..cee1e4013 100644 --- a/crates/engine/src/net/author.rs +++ b/crates/engine/src/net/author.rs @@ -354,8 +354,12 @@ mod tests { use cipherbox_core::content::{compute_cid, encode_content_cid_str}; use cipherbox_core::ipns::IpnsName; use cipherbox_core::kdf; - use cipherbox_core::seal::{decode_envelope, encode_grant_section, open_read_body}; + use cipherbox_core::seal::{ + GrantSetEntry, Permission, STRUCT_TAG_WRITE_BODY, StructureSigInput, decode_envelope, + encode_grant_section, open_read_body, sign_grant_set, sign_structure, + }; use cipherbox_core::suite::ecdsa::EcdsaSigner; + use cipherbox_core::suite::ed25519::Ed25519Signer; use crate::content::DAG_ROOT_CODEC; use crate::testkit::{OWNER_ROOT_EPOCH, OwnerRootFixture, OwnerRootSpec, owner_root_fixture}; @@ -537,6 +541,45 @@ mod tests { ); } + #[test] + fn a_scope_root_envelope_whose_section_has_two_signers_is_refused() { + // Release-active (security rule 8): the gate pins a section to the + // pseudonym its first structure verified under, so a section spread + // across two *committed* pseudonyms is unadoptable and must never be + // signed. Both signers are committed here, so only the pin rejects it. + let fixture = owner_root(); + let second = Ed25519Signer::from_seed([0x55; 32]); + let mut section = fixture.grant_section.clone(); + section.commitment.entries.push(GrantSetEntry::new( + [0x66; 32], + Permission::Write, + second.verifying_key().to_bytes(), + )); + section.commitment_sig = sign_grant_set( + &EcdsaSigner::from_scalar(&[0x11; 32]).expect("valid scalar"), + §ion.commitment, + ) + .expect("commitment signs") + .to_compact(); + let input = StructureSigInput::over_ciphertext( + [2u8; 16], + OWNER_ROOT_EPOCH, + STRUCT_TAG_WRITE_BODY, + None, + §ion.write_body.sealed, + ); + section.write_body.signature = sign_structure(&second, &input).to_bytes(); + assert_eq!( + author_scope_root_envelope( + authoring(&folder(), carried_section(§ion)), + &fixture.name, + &owner(), + ) + .unwrap_err(), + AuthorError::SectionSignatureInvalid, + ); + } + #[test] fn a_scope_root_envelope_whose_commitment_another_identity_signed_is_refused() { // Release-active (security rule 8): stage 3 authenticates structures diff --git a/crates/engine/tests/kat_gate.rs b/crates/engine/tests/kat_gate.rs new file mode 100644 index 000000000..c3af74cf9 --- /dev/null +++ b/crates/engine/tests/kat_gate.rs @@ -0,0 +1,186 @@ +//! The adoption gate's frozen stage-3 vectors: **one section, one signer** +//! (blueprint/engine.md "Adoption gate and floors"). +//! +//! Sibling of the content-DAG suite and self-contained — its own manifest under +//! `kat/gate/`, since the gate freezes a trust predicate over whole scope-root +//! head blocks rather than a content format. `crates/engine/kat` is written only +//! by `cargo run -p cipherbox-engine --example kat_gen`; CI diffs the +//! regenerated tree, so a verdict change that is not a deliberate re-freeze +//! fails there. + +use std::collections::BTreeSet; + +use cipherbox_core::error::TrustViolation; +use cipherbox_core::seal::{decode_envelope, decode_grant_section, grant_section_bytes}; +use cipherbox_core::suite::ecdsa::{EcdsaSignature, EcdsaVerifier}; +use cipherbox_engine::gate::authenticate_section_structures; +use serde::Deserialize; + +const MANIFEST: &str = include_str!("../kat/gate/manifest.json"); + +/// Every vector file the gate manifest may reference, keyed manifest-relative. +const FIXTURES: &[(&str, &str)] = &[ + ( + "vectors/section_signer_accept.json", + include_str!("../kat/gate/vectors/section_signer_accept.json"), + ), + ( + "vectors/section_signer_reject.json", + include_str!("../kat/gate/vectors/section_signer_reject.json"), + ), +]; + +// deny_unknown_fields: a field the schema does not know is a manifest drift, +// not a tolerance. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Manifest { + manifest_version: u64, + profile: String, + section_signer_accept: FileCount, + section_signer_reject: RejectSection, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FileCount { + file: String, + count: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RejectSection { + file: String, + count: usize, + checks: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SectionSignerVector { + name: String, + head_block: String, + owner_identity_pk: String, + check: Option, + class: Option, +} + +fn manifest() -> Manifest { + serde_json::from_str(MANIFEST).expect("gate manifest parses") +} + +fn vectors(file: &str) -> Vec { + let text = FIXTURES + .iter() + .find(|(p, _)| *p == file) + .unwrap_or_else(|| panic!("no embedded fixture for {file}")) + .1; + serde_json::from_str(text).expect("vector file parses") +} + +fn bytes(hex_str: &str) -> Vec { + hex::decode(hex_str).expect("vector hex decodes") +} + +/// Run the vector's head block through the gate's stage 2 and stage 3, in the +/// gate's own order. Stage 2 must pass on every vector — otherwise a stage-3 +/// vector would be proving nothing but a bad commitment signature. +fn authenticate(v: &SectionSignerVector) -> Result<(), cipherbox_core::error::CodecError> { + let envelope = decode_envelope(&bytes(&v.head_block)).expect("head block decodes"); + let section = decode_grant_section( + grant_section_bytes(&envelope).expect("a scope root carries its grant section"), + ) + .expect("grant section decodes"); + + let owner = EcdsaVerifier::from_sec1(&bytes(&v.owner_identity_pk)).expect("owner identity"); + let sig = EcdsaSignature::from_compact(§ion.commitment_sig).expect("commitment signature"); + cipherbox_core::seal::verify_grant_set(&owner, §ion.commitment, &sig) + .expect("every vector passes stage 2, so stage 3 owns the verdict"); + + authenticate_section_structures(§ion, &envelope) +} + +#[test] +fn manifest_header_and_counts_are_exact() { + let m = manifest(); + assert_eq!(m.manifest_version, 1); + assert_eq!(m.profile, "cipherbox/v2 engine adoption-gate"); + assert_eq!( + vectors(&m.section_signer_accept.file).len(), + m.section_signer_accept.count + ); + assert_eq!( + vectors(&m.section_signer_reject.file).len(), + m.section_signer_reject.count + ); + assert!( + m.section_signer_reject.count > 0, + "the reject family must not be empty" + ); + + let referenced: BTreeSet<&str> = [ + m.section_signer_accept.file.as_str(), + m.section_signer_reject.file.as_str(), + ] + .into_iter() + .collect(); + let embedded: BTreeSet<&str> = FIXTURES.iter().map(|(p, _)| *p).collect(); + assert_eq!( + referenced, embedded, + "every embedded fixture is referenced and every reference is embedded" + ); +} + +#[test] +fn accept_vectors_authenticate_under_one_committed_signer() { + let m = manifest(); + let vs = vectors(&m.section_signer_accept.file); + let mut names = BTreeSet::new(); + for v in &vs { + assert!(names.insert(v.name.clone()), "duplicate vector {}", v.name); + assert!(v.check.is_none() && v.class.is_none(), "{}", v.name); + authenticate(v).unwrap_or_else(|e| panic!("{}: {e}", v.name)); + } +} + +#[test] +fn a_section_signed_by_two_committed_pseudonyms_fails_closed() { + let m = manifest(); + let vs = vectors(&m.section_signer_reject.file); + let mut names = BTreeSet::new(); + let mut seen = BTreeSet::new(); + for v in &vs { + assert!(names.insert(v.name.clone()), "duplicate vector {}", v.name); + let error = authenticate(v).expect_err(&format!("{} must fail closed", v.name)); + assert_eq!(Some(error.check()), v.check.as_deref(), "{}", v.name); + assert_eq!(Some(error.class()), v.class.as_deref(), "{}", v.name); + seen.insert(error.check().to_string()); + } + assert_eq!( + seen.into_iter().collect::>(), + { + let mut c = m.section_signer_reject.checks.clone(); + c.sort(); + c + }, + "the manifest's check list is exactly what the vectors fire" + ); + assert!( + vs.iter().any(|v| v.name == "two-committed-signers"), + "the mixed-signer vector is the whole point of this family" + ); +} + +#[test] +fn every_gate_check_comes_from_cores_trust_surface() { + // The gate composes core's verify functions and invents no cryptographic + // error code of its own (blueprint/engine.md). + let m = manifest(); + for check in &m.section_signer_reject.checks { + assert!( + TrustViolation::CHECKS.contains(&check.as_str()), + "{check} is not a core trust verdict" + ); + } +} From bebdf6126eae6495ea9a0515ba88ad46aaac9850 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 6 Aug 2026 14:55:44 +0200 Subject: [PATCH 2/3] refactor: fold the review-gate findings into the section-signer pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2 --- blueprint/core.md | 10 +- blueprint/engine.md | 25 +- blueprint/testing.md | 14 +- crates/core/src/seal/section.rs | 11 +- crates/engine/examples/kat_gen.rs | 232 +++++++++++------- crates/engine/kat/gate/manifest.json | 4 +- .../gate/vectors/section_signer_accept.json | 9 +- .../gate/vectors/section_signer_reject.json | 9 +- crates/engine/src/gate/adoption.rs | 161 +++++++----- crates/engine/src/gate/mod.rs | 6 +- crates/engine/src/net/author.rs | 7 +- crates/engine/tests/kat_gate.rs | 137 ++++++++--- 12 files changed, 413 insertions(+), 212 deletions(-) diff --git a/blueprint/core.md b/blueprint/core.md index 0a51dc67c..fdaf4a59d 100644 --- a/blueprint/core.md +++ b/blueprint/core.md @@ -137,11 +137,11 @@ ownerPseudonymPk, [(tag, permission, pseudonymPk)]}`), owner blob, the optional fail-closed at decode and encode — `historyLinks` at 256, `grantBlobs` and the commitment's `entries` both at 1024 (`too-many-structures`) — and two history links may not carry - equal sealed bytes (`duplicate-history-link`): the gate resolves the section's - single signer by trial-verifying its first structure against the committed - pseudonyms, so an unbounded collection on **either** side of that - `pseudonyms + structures` sum is a reader-CPU amplifier, and each epoch mints - one link under a fresh nonce, so a repeat is an authored anomaly. The two 1024 ceilings + equal sealed bytes (`duplicate-history-link`): the gate's stage-3 work is + `pseudonyms + structures` (engine.md "One section, one signer"), so an + unbounded collection on **either** side of that sum is a reader-CPU amplifier, + and each epoch mints one link under a fresh nonce, so a repeat is an authored + anomaly. The two 1024 ceilings are one number: the ledger must match the committed set exactly and a re-seal wraps one blob per ledger row, so a commitment past the ceiling could only mint a section its own encoder refuses. `historyLinks` is ordered **oldest epoch diff --git a/blueprint/engine.md b/blueprint/engine.md index f53800106..1a68cf65e 100644 --- a/blueprint/engine.md +++ b/blueprint/engine.md @@ -182,13 +182,24 @@ re-signing at the record's read epoch even the history links it carries forward verbatim (`rotation/reseal.rs`). The gate therefore **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 -unadoptable, not merely unusual. Pinning is what bounds stage 3's work at -`pseudonyms + structures` rather than their product — without it an accepted -contact commits 1024 write pseudonyms of their own and spreads a section's -signatures across them, buying ~1000x reader-CPU amplification for ~1284 -signatures. The produce side runs the same predicate release-active -(`net/author.rs::check_scope_root`), so this build never signs a section its own -gate rejects. +unadoptable, not merely unusual. + +It closes 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 per-structure trial-verify +adopted it. It is also what bounds stage 3's work at `pseudonyms + structures` +rather than their product: without it an accepted contact commits 1024 write +pseudonyms of their own and spreads a section's signatures across them, buying +~1000x reader-CPU amplification for ~1284 signatures. The produce side runs the +same predicate release-active (`net/author.rs::check_scope_root`), so this build +never signs a section its own gate rejects. + +The pinned signer may be **any** committed write-capable pseudonym, not the +owner's specifically: the commitment is epoch-free so that grantee-triggered +rotation needs no owner signature (`CONTEXT.md`). Per-structure signers would +need a per-structure signer index on the wire, since the gate cannot otherwise +avoid the product — a format change, not a relaxation of this rule. A gate failure is never mere staleness: the engine pins last-known-good, raises the withheld-update escalation where applicable, and never renders the diff --git a/blueprint/testing.md b/blueprint/testing.md index 6cdcbf9be..d06780f1f 100644 --- a/blueprint/testing.md +++ b/blueprint/testing.md @@ -116,13 +116,13 @@ Adversarial cases are first-class: the harness can replay, transplant, and re-sign records with any key it holds; every crypto-review finding (#35) gets a pinned regression scenario. -The engine also ships **its own KAT sets**, under core's regime but for the -formats and predicates core cannot reach: `kat/` freezes the content-DAG root, -`kat/gate/` freezes the adoption gate's stage-3 verdict over whole scope-root -head blocks — including the **one section, one signer** reject. Both are -written only by `cargo run -p cipherbox-engine --example kat_gen`, and the -**Engine Tests** gate regenerates the tree and diffs it before running the -suites, so a verdict change that is not a deliberate re-freeze fails there. +The engine also ships **its own KAT vectors**, under core's regime but for the +formats and predicates core cannot reach: the content-DAG root, and the +adoption gate's stage-3 verdict over whole scope-root head blocks — including +the **one section, one signer** reject. They are written only by +`cargo run -p cipherbox-engine --example kat_gen`, and the **Engine Tests** gate +regenerates all of `crates/engine/kat` and diffs it before running the suites, +so a verdict change that is not a deliberate re-freeze fails there. ### The contract suite — the live API gate diff --git a/crates/core/src/seal/section.rs b/crates/core/src/seal/section.rs index 987347b48..b76e316cb 100644 --- a/crates/core/src/seal/section.rs +++ b/crates/core/src/seal/section.rs @@ -264,11 +264,12 @@ pub struct GrantSection { pub unknown: PreservedFields, } -/// The frozen bound on a section's history links. The gate verifies one -/// signature per structure per committed pseudonym, so an unbounded collection -/// lets one record dictate another reader's CPU budget. Producers prune to a far -/// smaller retained window (`rotation/reseal.rs`), leaving this a -/// malformed-input guard an honest rotator never approaches. +/// The frozen bound on a section's history links: the gate's stage-3 work is +/// `pseudonyms + structures` (blueprint/engine.md "One section, one signer"), so +/// an unbounded collection on either side lets one record dictate another +/// reader's CPU budget. Producers prune to a far smaller retained window +/// (`rotation/reseal.rs`), leaving this a malformed-input guard an honest +/// rotator never approaches. pub const MAX_HISTORY_LINKS: usize = 256; /// The frozen bound on a section's grant blobs — one per committed grantee. diff --git a/crates/engine/examples/kat_gen.rs b/crates/engine/examples/kat_gen.rs index 08d78db2a..e4476c342 100644 --- a/crates/engine/examples/kat_gen.rs +++ b/crates/engine/examples/kat_gen.rs @@ -24,8 +24,10 @@ use cipherbox_core::content::{CONTENT_CID_CODEC, compute_cid, encode_content_cid use cipherbox_core::error::TrustViolation; use cipherbox_core::kdf; use cipherbox_core::seal::{ - GrantSetEntry, Permission, STRUCT_TAG_WRITE_BODY, StructureSigInput, encode_envelope, - encode_grant_section, set_grant_section, sign_grant_set, sign_structure, + GrantSection, GrantSetEntry, Permission, PreservedFields, STRUCT_TAG_GRANT_BLOB, + STRUCT_TAG_OWNER_BLOB, STRUCT_TAG_OWNER_WRITE_BLOB, STRUCT_TAG_WRITE_BODY, SignedGrantBlob, + StructureSigInput, encode_envelope, encode_grant_section, set_grant_section, sign_grant_set, + sign_structure, }; use cipherbox_core::suite::aead::KEY_LEN; use cipherbox_core::suite::ecdsa::EcdsaSigner; @@ -205,7 +207,10 @@ fn main() { dag_root_reject: RejectSection { file: "vectors/content/dag_root_reject.json".to_string(), count: root_reject.len(), - checks: checks_in_surface_order(root_reject.iter().map(|v| v.check.as_str())), + checks: checks_in_surface_order( + DagError::CHECKS, + root_reject.iter().map(|v| v.check.as_str()), + ), }, dag_capacity_accept: FileCount { file: "vectors/content/dag_capacity_accept.json".to_string(), @@ -214,7 +219,7 @@ fn main() { dag_capacity_reject: RejectSection { file: "vectors/content/dag_capacity_reject.json".to_string(), count: 1, - checks: checks_in_surface_order([capacity_reject.check.as_str()]), + checks: checks_in_surface_order(DagError::CHECKS, [capacity_reject.check.as_str()]), }, }, }; @@ -245,7 +250,8 @@ fn main() { section_signer_reject: RejectSection { file: "vectors/section_signer_reject.json".to_string(), count: signer_reject.len(), - checks: trust_checks_in_surface_order( + checks: checks_in_surface_order( + TrustViolation::CHECKS, signer_reject.iter().map(|v| v.check.as_deref().unwrap()), ), }, @@ -268,109 +274,171 @@ fn write_pretty(path: &Path, value: &T) { fs::write(path, text).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); } -/// The distinct checks in declaration order, asserting each comes from -/// [`DagError::CHECKS`] (a reject vector can never name an off-surface check). -fn checks_in_surface_order<'a>(present: impl IntoIterator) -> Vec { +/// The distinct checks in `surface` declaration order, asserting every one is on +/// that surface — a reject vector can never name an off-surface check. +fn checks_in_surface_order<'a>( + surface: &[&str], + present: impl IntoIterator, +) -> Vec { let present: BTreeSet<&str> = present.into_iter().collect(); - let checks: Vec = DagError::CHECKS + let checks: Vec = surface .iter() .filter(|c| present.contains(*c)) .map(|c| (*c).to_string()) .collect(); - assert_eq!( - checks.len(), - present.len(), - "every reject-vector check must come from the DAG error surface" - ); + assert_eq!(checks.len(), present.len(), "off-surface reject check"); checks } -/// The distinct checks in core's trust-surface order, asserting each comes from -/// [`TrustViolation::CHECKS`] — the gate invents no cryptographic error code of -/// its own, so a gate reject vector can only name a core trust verdict. -fn trust_checks_in_surface_order<'a>(present: impl IntoIterator) -> Vec { - let present: BTreeSet<&str> = present.into_iter().collect(); - let checks: Vec = TrustViolation::CHECKS - .iter() - .filter(|c| present.contains(*c)) - .map(|c| (*c).to_string()) - .collect(); - assert_eq!( - checks.len(), - present.len(), - "every gate reject-vector check must come from core's trust surface" - ); - checks -} - -/// Stage 3's **one section, one signer** rule frozen as vectors -/// (blueprint/engine.md "Adoption gate and floors"): a real owner root, and the -/// same root with one structure re-signed under a *second committed* write -/// pseudonym. Both pass stage 2 — the reject is stage 3's alone. +/// The gate KAT's own key axis. Nonces and HPKE ephemerals are fixed inside the +/// fixture, so a spec that shares `root_id`/`owner_enc` with another shares a +/// (key, nonce) pair (`testkit/owner_root.rs`) — and this set freezes its +/// ciphertexts in a committed artifact. +const GATE_KAT_SCOPE: [u8; 16] = [0x2a; 16]; +const GATE_KAT_ROOT: [u8; 16] = [0x1b; 16]; +const GATE_KAT_OWNER_ENC_SEED: [u8; 32] = [0x3c; 32]; +/// The committed write-grantee's blinded tag and pseudonym seed. +const GATE_KAT_GRANTEE_TAG: [u8; 32] = [0x66; 32]; +const GATE_KAT_GRANTEE_PSEUDONYM_SEED: [u8; 32] = [0x55; 32]; + +/// Stage 3's **one section, one signer** rule frozen over whole scope-root head +/// blocks (blueprint/engine.md "Adoption gate and floors"). +/// +/// Every vector shares one commitment naming two write-capable pseudonyms, so +/// the accept family pins that pinning narrows how many signers a section may +/// have and not which pseudonym may sign it, and every reject carries structure +/// signatures that are each individually valid under a committed pseudonym — +/// only the pin refuses them. fn build_section_signer_vectors() -> (Vec, Vec) { let owner_identity = EcdsaSigner::from_scalar(&[0x11; 32]).expect("valid scalar"); - let owner_enc = kdf::enc_subkey(&[0x33; 32]).public(); + let owner_enc = kdf::enc_subkey(&GATE_KAT_OWNER_ENC_SEED).public(); let fixture = owner_root_fixture(OwnerRootSpec { owner_identity: &owner_identity, owner_enc: &owner_enc, - scope_id: [2u8; 16], - root_id: [1u8; 16], + scope_id: GATE_KAT_SCOPE, + root_id: GATE_KAT_ROOT, children: Vec::new(), child_scope_index: Vec::new(), parent_node_seed: None, owner_write_blob_epoch: Some(OWNER_ROOT_EPOCH), }); let owner_identity_pk = hex::encode(owner_identity.verifying_key().to_sec1()); + let grantee = Ed25519Signer::from_seed(GATE_KAT_GRANTEE_PSEUDONYM_SEED); + let by_grantee = |tag: u8, recipient: Option<[u8; 32]>, ct: &[u8]| -> [u8; 64] { + let input = StructureSigInput::over_ciphertext( + GATE_KAT_SCOPE, + OWNER_ROOT_EPOCH, + tag, + recipient, + ct, + ); + sign_structure(&grantee, &input).to_bytes() + }; - // The attacker's own committed write pseudonym: a section that switches to - // it part-way is what stage 3 used to trial-verify against the whole set. - let second = Ed25519Signer::from_seed([0x55; 32]); - let mut section = fixture.grant_section.clone(); - section.commitment.entries.push(GrantSetEntry::new( - [0x66; 32], - Permission::Write, - second.verifying_key().to_bytes(), - )); - let input = StructureSigInput::over_ciphertext( - [2u8; 16], - OWNER_ROOT_EPOCH, - STRUCT_TAG_WRITE_BODY, + // One commitment for every vector, naming the owner's pseudonym and a write + // grantee's, so `committed_write_pseudonyms` is never a one-element set a + // pin could satisfy vacuously. + let committed = { + let mut section = fixture.grant_section.clone(); + section.commitment.entries.push(GrantSetEntry::new( + GATE_KAT_GRANTEE_TAG, + Permission::Write, + grantee.verifying_key().to_bytes(), + )); + section.commitment_sig = sign_grant_set(&owner_identity, §ion.commitment) + .expect("commitment signs") + .to_compact(); + section + }; + let head_block = |section: &GrantSection| { + let mut envelope = fixture.envelope.clone(); + set_grant_section( + &mut envelope, + encode_grant_section(section).expect("section encodes"), + ); + encode_envelope(&envelope).expect("envelope encodes") + }; + + // Accept: the whole section under the grantee's pseudonym — a committed + // signer that is neither the owner's nor first in the trial order. + let mut grantee_signed = committed.clone(); + grantee_signed.owner_blob.signature = by_grantee( + STRUCT_TAG_OWNER_BLOB, None, - §ion.write_body.sealed, + &grantee_signed.owner_blob.ciphertext, ); - section.write_body.signature = sign_structure(&second, &input).to_bytes(); - section.commitment_sig = sign_grant_set(&owner_identity, §ion.commitment) - .expect("commitment signs") - .to_compact(); - - let mut envelope = fixture.envelope.clone(); - set_grant_section( - &mut envelope, - encode_grant_section(§ion).expect("section encodes"), + { + let blob = grantee_signed + .owner_write_blob + .as_mut() + .expect("the spec authors one"); + blob.signature = by_grantee(STRUCT_TAG_OWNER_WRITE_BLOB, None, &blob.ciphertext); + } + grantee_signed.write_body.signature = by_grantee( + STRUCT_TAG_WRITE_BODY, + None, + &grantee_signed.write_body.sealed, ); - let mixed_block = encode_envelope(&envelope).expect("envelope encodes"); - authenticate_section_structures(&fixture.grant_section, &fixture.envelope) - .expect("a single-signer section authenticates"); - let error = authenticate_section_structures(§ion, &envelope) - .expect_err("a two-signer section must fail closed"); + // Reject: the owner's section with the write-body re-signed by the grantee — + // the shape that used to force the full trial-verify product. + let mut two_signers = committed.clone(); + two_signers.write_body.signature = + by_grantee(STRUCT_TAG_WRITE_BODY, None, &two_signers.write_body.sealed); + + // Reject: a structure splice. The grant blob is verbatim another committed + // writer's work at this scope and epoch, so its signature recomputes + // identically here — the integrity hole the pin closes, and the only vector + // exercising the `recipientTag` arm of the signed input. + let mut spliced = committed.clone(); + let ciphertext = b"a grant blob lifted from another committed writer".to_vec(); + spliced.grant_blobs.push(SignedGrantBlob { + tag: GATE_KAT_GRANTEE_TAG, + enc: [0x7d; 32], + signature: by_grantee( + STRUCT_TAG_GRANT_BLOB, + Some(GATE_KAT_GRANTEE_TAG), + &ciphertext, + ), + ciphertext, + unknown: PreservedFields::new(), + }); - ( - vec![SectionSignerVector { - name: "single-signer-section".to_string(), - head_block: hex::encode(&fixture.head_block), + let vector = |name: &str, section: &GrantSection, verdict: Option<(String, String)>| { + let (check, class) = verdict.unzip(); + SectionSignerVector { + name: name.to_string(), + head_block: hex::encode(head_block(section)), owner_identity_pk: owner_identity_pk.clone(), - check: None, - class: None, - }], - vec![SectionSignerVector { - name: "two-committed-signers".to_string(), - head_block: hex::encode(&mixed_block), - owner_identity_pk, - check: Some(error.check().to_string()), - class: Some(error.class().to_string()), - }], - ) + check, + class, + } + }; + let accept_out = [ + ("single-signer-owner-pseudonym", committed), + ("single-signer-committed-grantee", grantee_signed), + ] + .iter() + .map(|(name, section)| { + authenticate_section_structures(section, &fixture.envelope) + .unwrap_or_else(|e| panic!("{name}: a single-signer section must authenticate: {e}")); + vector(name, section, None) + }) + .collect(); + + let reject_out = [ + ("two-committed-signers", two_signers), + ("spliced-structure-from-another-committed-signer", spliced), + ] + .iter() + .map(|(name, section)| { + let error = authenticate_section_structures(section, &fixture.envelope) + .expect_err("a section with two committed signers must fail closed"); + let verdict = (error.check().to_string(), error.class().to_string()); + vector(name, section, Some(verdict)) + }) + .collect(); + (accept_out, reject_out) } /// Deterministic plaintext of `len` bytes. diff --git a/crates/engine/kat/gate/manifest.json b/crates/engine/kat/gate/manifest.json index 3a4fe7356..54a5befeb 100644 --- a/crates/engine/kat/gate/manifest.json +++ b/crates/engine/kat/gate/manifest.json @@ -3,11 +3,11 @@ "profile": "cipherbox/v2 engine adoption-gate", "sectionSignerAccept": { "file": "vectors/section_signer_accept.json", - "count": 1 + "count": 2 }, "sectionSignerReject": { "file": "vectors/section_signer_reject.json", - "count": 1, + "count": 2, "checks": [ "structure-signature-invalid" ] diff --git a/crates/engine/kat/gate/vectors/section_signer_accept.json b/crates/engine/kat/gate/vectors/section_signer_accept.json index dafb316e1..15f52e6ee 100644 --- a/crates/engine/kat/gate/vectors/section_signer_accept.json +++ b/crates/engine/kat/gate/vectors/section_signer_accept.json @@ -1,7 +1,12 @@ [ { - "name": "single-signer-section", - "headBlock": "a561760162696450010101010101010101010101010101016865706f6368546167a26565706f6368016573636f706550020202020202020202020202020202026a726561645365616c656458560b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b521da1f96fce302b1a11af940c05829dd9ec63203d88e3dd3085e4f7bf234af14d1fbeed5b60080ee0d823b7f2c2c195c1926d750df600f574242b1b7f646c6772616e7453656374696f6e590359a7696f776e6572426c6f62a363656e6358205dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef22637369675840e7dd1d97334aa51a1a3073301155dd8f1b297e3416edf1168eaa9dbaf030a83a9a6d79a6802d945eb1ce7be958b4d3df846befa18faa358ddb42d1f2d1f6b0006a6369706865727465787458472dd0309ab5944853162209866edc6e701a6a398ca00c64ef5ebf50c01e6aae10b25c448cda2249379163bab060516a269ac6a4d603fda3e2a84f919c8ce04eeff97deac4bd64e7697772697465426f6479a2637369675840c43925fcfb040376c20c7ad0cf4d3c3b908779dc08650660fcec66fc5246c9ae9c1394ebaa0585c63d8bb4ff4156870873073457d95b525e440ee9f50ee8ab0c667365616c6564585f16161616161616161616161616161616161616161616161661273f65ddf07b48211e3ee0e9946c770bc381cba4fd144453a3879999dcf8c5fbe64261460d4dd9bed5a3ed551f1bc316182d03f2bd029db126b14de066dff615f3d3d46b1a956a636f6d6d69746d656e745886a367656e7472696573806869706e734e616d65583e6b3531717a693575717535646b656b7970627475786e6c333237657478676b6c386c6732733076776264346f7931303469346774656f7864773179756238706f776e657250736575646f6e796d506b5820a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f06a6772616e74426c6f6273806c686973746f72794c696e6b73806d636f6d6d69746d656e745369675840b837d7ffba80f519c1594e66609291ab24d087907609848966ec6ed196a1c627173de86a956b1b4a344c4d8a6bba3533042dc797f05ef6afa71e707939ba41b56e6f776e65725772697465426c6f62a363656e635820ac01b2209e86354fb853237b5de0f4fab13c7fcbf433a61c019369617fecf10b6373696758406e2e4ffd34fbd59993ffae386fc8daa2b5d3f655712392f231e34a8a9672d62e57e9f5b833fe33cdf76ecc7946e559572092dd14572821363cebedf2f87092076a63697068657274657874584e14f26b8d7c113806320bb72398d11be5a4fdb095fce40b39d783ce97002c65ab2de18579ebf475a6effcd0954a9e95a9162d58e913007223041ce4402a5d139019a457c211069a4d74875e9eeb2c", + "name": "single-signer-owner-pseudonym", + "headBlock": "a5617601626964501b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b6865706f6368546167a26565706f6368016573636f7065502a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a6a726561645365616c656458560b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0bf077e32e6cb8b1cc6d2f9234188714c3560d626e19e014587b5985781668387cdf6540ea67663c2d259afc956168bd5072dab4a3b77c8e1ed325a27a43c06c6772616e7453656374696f6e5903bfa7696f776e6572426c6f62a363656e6358205dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef226373696758406e117be4f9faf98b544eb907fb7ddec2482919044638c870d52247b64ce10117e1a6dbf03a4218f910e63e195c353dec8d9f5785c028eb859925b427ed41db066a6369706865727465787458470cbe2c46be0ffe58cfbb155a13114c3bd49cc36ef2918af7c11868be85b2e0fb82915dbb530bad5c207087138849c60258537a05b6dd98568134ea0ce816214c4501084e9d3d32697772697465426f6479a263736967584006c990bd8ddd18c6bfcd728f3e8fcaf21735b57912a017aced83403aa902f9cd91f68104e3714988c430bbe0c50115417920fc03030864d437d5375dd27bf50a667365616c6564585f16161616161616161616161616161616161616161616161604c3c92340e823b3fca8fdb6a42923cfdf2d696eda793e9500ac9d7eef330c3e8c6b5759ff819d9fb150b232184ea2cf2c5390fec47aff3280dea83901030d791d42314360c11d6a636f6d6d69746d656e7458eca367656e747269657381a363746167582066666666666666666666666666666666666666666666666666666666666666666a7065726d697373696f6e6577726974656b70736575646f6e796d506b5820c6822637c7d310ec57627be00ba259d253749f4aaf644470cffbe53a35f732426869706e734e616d65583e6b3531717a693575717535646964306d737a38776164376a6a6c3664656a356862733572643075333975727531666f3474637876367438363075766f3861706f776e657250736575646f6e796d506b5820a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f06a6772616e74426c6f6273806c686973746f72794c696e6b73806d636f6d6d69746d656e745369675840bb14d4f8773c392c165decb3d5454cf7d7524f13791a72bc0d6db5404e0e932f171a7a310933b3a3541220beb861def88cc7eccc39189f0db178d9ee5ae7cc0c6e6f776e65725772697465426c6f62a363656e635820ac01b2209e86354fb853237b5de0f4fab13c7fcbf433a61c019369617fecf10b6373696758409e2f235744ae2f4d20a2ecdcb79af6bf28f5013090ea1bffc74167d0d13f23908a72f751846f3896a2e6bdcdd1c0d22be057501e18ed7b73031db9cb76209d0f6a63697068657274657874584ef1b230df5a41e995e27bf2cadfca8588f602a8281438fa74e1247db88ab14d81d84664768e40cd8a533e58959ffc39a90e7cfb6d40448bfb5d839e4117c0ab4eb5332fe436db775d22a476c3f338", + "ownerIdentityPk": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + }, + { + "name": "single-signer-committed-grantee", + "headBlock": "a5617601626964501b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b6865706f6368546167a26565706f6368016573636f7065502a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a6a726561645365616c656458560b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0bf077e32e6cb8b1cc6d2f9234188714c3560d626e19e014587b5985781668387cdf6540ea67663c2d259afc956168bd5072dab4a3b77c8e1ed325a27a43c06c6772616e7453656374696f6e5903bfa7696f776e6572426c6f62a363656e6358205dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef226373696758402917bcfe5c87c09c4fd8461db262e0332f8721e07817078ad90cde44a881767894f1f0f37a6c457df0cef9d39ec969c20080447d0080651eb0e31664e6376a0b6a6369706865727465787458470cbe2c46be0ffe58cfbb155a13114c3bd49cc36ef2918af7c11868be85b2e0fb82915dbb530bad5c207087138849c60258537a05b6dd98568134ea0ce816214c4501084e9d3d32697772697465426f6479a2637369675840fc1c24382a150f0cb4a93da7e61f3769c053fb21ec9adad24224b2eb8d4648319f9c9a6c61dd60180e181571dbc6a5ded3394586c67ce70e928cdaaf0a24a501667365616c6564585f16161616161616161616161616161616161616161616161604c3c92340e823b3fca8fdb6a42923cfdf2d696eda793e9500ac9d7eef330c3e8c6b5759ff819d9fb150b232184ea2cf2c5390fec47aff3280dea83901030d791d42314360c11d6a636f6d6d69746d656e7458eca367656e747269657381a363746167582066666666666666666666666666666666666666666666666666666666666666666a7065726d697373696f6e6577726974656b70736575646f6e796d506b5820c6822637c7d310ec57627be00ba259d253749f4aaf644470cffbe53a35f732426869706e734e616d65583e6b3531717a693575717535646964306d737a38776164376a6a6c3664656a356862733572643075333975727531666f3474637876367438363075766f3861706f776e657250736575646f6e796d506b5820a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f06a6772616e74426c6f6273806c686973746f72794c696e6b73806d636f6d6d69746d656e745369675840bb14d4f8773c392c165decb3d5454cf7d7524f13791a72bc0d6db5404e0e932f171a7a310933b3a3541220beb861def88cc7eccc39189f0db178d9ee5ae7cc0c6e6f776e65725772697465426c6f62a363656e635820ac01b2209e86354fb853237b5de0f4fab13c7fcbf433a61c019369617fecf10b637369675840d74227d638a0e7f3f7887fce3de298e65ede12012e59ecc0d28df8252e818dfe624b1a6d1260f348e345147310b58169785c4f711fc3fc8af7d74c00ca0510016a63697068657274657874584ef1b230df5a41e995e27bf2cadfca8588f602a8281438fa74e1247db88ab14d81d84664768e40cd8a533e58959ffc39a90e7cfb6d40448bfb5d839e4117c0ab4eb5332fe436db775d22a476c3f338", "ownerIdentityPk": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" } ] diff --git a/crates/engine/kat/gate/vectors/section_signer_reject.json b/crates/engine/kat/gate/vectors/section_signer_reject.json index b30964303..b52cb1ed0 100644 --- a/crates/engine/kat/gate/vectors/section_signer_reject.json +++ b/crates/engine/kat/gate/vectors/section_signer_reject.json @@ -1,7 +1,14 @@ [ { "name": "two-committed-signers", - "headBlock": "a561760162696450010101010101010101010101010101016865706f6368546167a26565706f6368016573636f706550020202020202020202020202020202026a726561645365616c656458560b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b521da1f96fce302b1a11af940c05829dd9ec63203d88e3dd3085e4f7bf234af14d1fbeed5b60080ee0d823b7f2c2c195c1926d750df600f574242b1b7f646c6772616e7453656374696f6e5903bfa7696f776e6572426c6f62a363656e6358205dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef22637369675840e7dd1d97334aa51a1a3073301155dd8f1b297e3416edf1168eaa9dbaf030a83a9a6d79a6802d945eb1ce7be958b4d3df846befa18faa358ddb42d1f2d1f6b0006a6369706865727465787458472dd0309ab5944853162209866edc6e701a6a398ca00c64ef5ebf50c01e6aae10b25c448cda2249379163bab060516a269ac6a4d603fda3e2a84f919c8ce04eeff97deac4bd64e7697772697465426f6479a2637369675840998cc940d34430978b41196e9540935471e703b73d81b1964a500d5998febcc97ae86595860b9fdb7a83de8736ce0496b711732231fd3a6d872c5ba852a1cc01667365616c6564585f16161616161616161616161616161616161616161616161661273f65ddf07b48211e3ee0e9946c770bc381cba4fd144453a3879999dcf8c5fbe64261460d4dd9bed5a3ed551f1bc316182d03f2bd029db126b14de066dff615f3d3d46b1a956a636f6d6d69746d656e7458eca367656e747269657381a363746167582066666666666666666666666666666666666666666666666666666666666666666a7065726d697373696f6e6577726974656b70736575646f6e796d506b5820c6822637c7d310ec57627be00ba259d253749f4aaf644470cffbe53a35f732426869706e734e616d65583e6b3531717a693575717535646b656b7970627475786e6c333237657478676b6c386c6732733076776264346f7931303469346774656f7864773179756238706f776e657250736575646f6e796d506b5820a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f06a6772616e74426c6f6273806c686973746f72794c696e6b73806d636f6d6d69746d656e7453696758401b9525b6bc42c212304fef05e9e649aaf0abcc6730dff236f9a33006f51965ae73429e22dfbc82c05ee263938fec2c979988e2b8cf30d8e4bd59b77b41285f3f6e6f776e65725772697465426c6f62a363656e635820ac01b2209e86354fb853237b5de0f4fab13c7fcbf433a61c019369617fecf10b6373696758406e2e4ffd34fbd59993ffae386fc8daa2b5d3f655712392f231e34a8a9672d62e57e9f5b833fe33cdf76ecc7946e559572092dd14572821363cebedf2f87092076a63697068657274657874584e14f26b8d7c113806320bb72398d11be5a4fdb095fce40b39d783ce97002c65ab2de18579ebf475a6effcd0954a9e95a9162d58e913007223041ce4402a5d139019a457c211069a4d74875e9eeb2c", + "headBlock": "a5617601626964501b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b6865706f6368546167a26565706f6368016573636f7065502a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a6a726561645365616c656458560b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0bf077e32e6cb8b1cc6d2f9234188714c3560d626e19e014587b5985781668387cdf6540ea67663c2d259afc956168bd5072dab4a3b77c8e1ed325a27a43c06c6772616e7453656374696f6e5903bfa7696f776e6572426c6f62a363656e6358205dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef226373696758406e117be4f9faf98b544eb907fb7ddec2482919044638c870d52247b64ce10117e1a6dbf03a4218f910e63e195c353dec8d9f5785c028eb859925b427ed41db066a6369706865727465787458470cbe2c46be0ffe58cfbb155a13114c3bd49cc36ef2918af7c11868be85b2e0fb82915dbb530bad5c207087138849c60258537a05b6dd98568134ea0ce816214c4501084e9d3d32697772697465426f6479a2637369675840fc1c24382a150f0cb4a93da7e61f3769c053fb21ec9adad24224b2eb8d4648319f9c9a6c61dd60180e181571dbc6a5ded3394586c67ce70e928cdaaf0a24a501667365616c6564585f16161616161616161616161616161616161616161616161604c3c92340e823b3fca8fdb6a42923cfdf2d696eda793e9500ac9d7eef330c3e8c6b5759ff819d9fb150b232184ea2cf2c5390fec47aff3280dea83901030d791d42314360c11d6a636f6d6d69746d656e7458eca367656e747269657381a363746167582066666666666666666666666666666666666666666666666666666666666666666a7065726d697373696f6e6577726974656b70736575646f6e796d506b5820c6822637c7d310ec57627be00ba259d253749f4aaf644470cffbe53a35f732426869706e734e616d65583e6b3531717a693575717535646964306d737a38776164376a6a6c3664656a356862733572643075333975727531666f3474637876367438363075766f3861706f776e657250736575646f6e796d506b5820a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f06a6772616e74426c6f6273806c686973746f72794c696e6b73806d636f6d6d69746d656e745369675840bb14d4f8773c392c165decb3d5454cf7d7524f13791a72bc0d6db5404e0e932f171a7a310933b3a3541220beb861def88cc7eccc39189f0db178d9ee5ae7cc0c6e6f776e65725772697465426c6f62a363656e635820ac01b2209e86354fb853237b5de0f4fab13c7fcbf433a61c019369617fecf10b6373696758409e2f235744ae2f4d20a2ecdcb79af6bf28f5013090ea1bffc74167d0d13f23908a72f751846f3896a2e6bdcdd1c0d22be057501e18ed7b73031db9cb76209d0f6a63697068657274657874584ef1b230df5a41e995e27bf2cadfca8588f602a8281438fa74e1247db88ab14d81d84664768e40cd8a533e58959ffc39a90e7cfb6d40448bfb5d839e4117c0ab4eb5332fe436db775d22a476c3f338", + "ownerIdentityPk": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa", + "check": "structure-signature-invalid", + "class": "trust" + }, + { + "name": "spliced-structure-from-another-committed-signer", + "headBlock": "a5617601626964501b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b6865706f6368546167a26565706f6368016573636f7065502a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a6a726561645365616c656458560b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0bf077e32e6cb8b1cc6d2f9234188714c3560d626e19e014587b5985781668387cdf6540ea67663c2d259afc956168bd5072dab4a3b77c8e1ed325a27a43c06c6772616e7453656374696f6e590490a7696f776e6572426c6f62a363656e6358205dfedd3b6bd47f6fa28ee15d969d5bb0ea53774d488bdaf9df1c6e0124b3ef226373696758406e117be4f9faf98b544eb907fb7ddec2482919044638c870d52247b64ce10117e1a6dbf03a4218f910e63e195c353dec8d9f5785c028eb859925b427ed41db066a6369706865727465787458470cbe2c46be0ffe58cfbb155a13114c3bd49cc36ef2918af7c11868be85b2e0fb82915dbb530bad5c207087138849c60258537a05b6dd98568134ea0ce816214c4501084e9d3d32697772697465426f6479a263736967584006c990bd8ddd18c6bfcd728f3e8fcaf21735b57912a017aced83403aa902f9cd91f68104e3714988c430bbe0c50115417920fc03030864d437d5375dd27bf50a667365616c6564585f16161616161616161616161616161616161616161616161604c3c92340e823b3fca8fdb6a42923cfdf2d696eda793e9500ac9d7eef330c3e8c6b5759ff819d9fb150b232184ea2cf2c5390fec47aff3280dea83901030d791d42314360c11d6a636f6d6d69746d656e7458eca367656e747269657381a363746167582066666666666666666666666666666666666666666666666666666666666666666a7065726d697373696f6e6577726974656b70736575646f6e796d506b5820c6822637c7d310ec57627be00ba259d253749f4aaf644470cffbe53a35f732426869706e734e616d65583e6b3531717a693575717535646964306d737a38776164376a6a6c3664656a356862733572643075333975727531666f3474637876367438363075766f3861706f776e657250736575646f6e796d506b5820a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f06a6772616e74426c6f627381a463656e6358207d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d637369675840d7ad719794ddef906885d4da5849299d251945d484debacb257ba9dbcbef0a4cb63cee7113a27c85d0cf6f1f5a8325c52d7879b55a04551f6d691d4b0f82e60e63746167582066666666666666666666666666666666666666666666666666666666666666666a63697068657274657874583161206772616e7420626c6f62206c69667465642066726f6d20616e6f7468657220636f6d6d6974746564207772697465726c686973746f72794c696e6b73806d636f6d6d69746d656e745369675840bb14d4f8773c392c165decb3d5454cf7d7524f13791a72bc0d6db5404e0e932f171a7a310933b3a3541220beb861def88cc7eccc39189f0db178d9ee5ae7cc0c6e6f776e65725772697465426c6f62a363656e635820ac01b2209e86354fb853237b5de0f4fab13c7fcbf433a61c019369617fecf10b6373696758409e2f235744ae2f4d20a2ecdcb79af6bf28f5013090ea1bffc74167d0d13f23908a72f751846f3896a2e6bdcdd1c0d22be057501e18ed7b73031db9cb76209d0f6a63697068657274657874584ef1b230df5a41e995e27bf2cadfca8588f602a8281438fa74e1247db88ab14d81d84664768e40cd8a533e58959ffc39a90e7cfb6d40448bfb5d839e4117c0ab4eb5332fe436db775d22a476c3f338", "ownerIdentityPk": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa", "check": "structure-signature-invalid", "class": "trust" diff --git a/crates/engine/src/gate/adoption.rs b/crates/engine/src/gate/adoption.rs index 5044ca1e4..48c1696ee 100644 --- a/crates/engine/src/gate/adoption.rs +++ b/crates/engine/src/gate/adoption.rs @@ -306,14 +306,19 @@ impl PendingAdoption { } } -/// The committed write-capable pseudonyms of a scope root: the owner pseudonym -/// plus every write-permission entry's pseudonym. Read-only entries never +/// The committed write-capable pseudonym keys of a scope root: the owner +/// pseudonym plus every write-permission entry's. Read-only entries never /// authorize a seed-bearing structure. /// /// Deduplicated: only a tag is unique across committed entries, so one pseudonym /// may be named by many. A repeat authenticates nothing the first copy did not, -/// and each copy would cost every structure another trial verification. -fn committed_write_pseudonyms(commitment: &GrantSetCommitment) -> Vec { +/// and each copy would cost another trial verification. +/// +/// Left compressed. A commitment may name 1024 writers while the pin means at +/// most one is ever used, so decompressing eagerly would reinstate an +/// O(pseudonyms) cost the scan itself no longer pays. +#[doc(hidden)] +pub fn committed_write_pseudonyms(commitment: &GrantSetCommitment) -> Vec<[u8; 32]> { let writers = commitment .entries .iter() @@ -323,22 +328,17 @@ fn committed_write_pseudonyms(commitment: &GrantSetCommitment) -> Vec, - pinned: Option, + committed: Vec<[u8; 32]>, + pinned: Option, } impl StructureAuthenticator { @@ -367,12 +367,18 @@ impl StructureAuthenticator { let input = StructureSigInput::over_ciphertext(scope, epoch, struct_tag, recipient_tag, ciphertext); let sig = Ed25519Signature::from_bytes(*signature); - if let Some(i) = self.pinned { - return verify_structure(&self.committed[i], &input, &sig); + if let Some(pinned) = &self.pinned { + return verify_structure(pinned, &input, &sig); } - for (i, pseudonym) in self.committed.iter().enumerate() { - if verify_structure(pseudonym, &input, &sig).is_ok() { - self.pinned = Some(i); + // A pseudonym that is not a valid point verifies nothing, so a failed + // decompression falls through exactly as a failed signature does. + for pseudonym in self + .committed + .iter() + .filter_map(|pk| Ed25519Verifier::from_bytes(*pk)) + { + if verify_structure(&pseudonym, &input, &sig).is_ok() { + self.pinned = Some(pseudonym); return Ok(()); } } @@ -380,14 +386,62 @@ impl StructureAuthenticator { } } +/// Visit every seed-bearing structure `section` carries — its `structTag`, +/// recipient tag, signed-over ciphertext and detached signature — short-circuit +/// on the first `Err`. The single definition of *what* stage 3 authenticates, +/// so a new structure kind cannot reach the wire covered by only some of the +/// passes that walk one. +#[doc(hidden)] +pub fn for_each_structure( + section: &GrantSection, + mut visit: impl FnMut(u8, Option<[u8; 32]>, &[u8], &[u8; 64]) -> Result<(), E>, +) -> Result<(), E> { + let owner = §ion.owner_blob; + visit( + STRUCT_TAG_OWNER_BLOB, + None, + &owner.ciphertext, + &owner.signature, + )?; + if let Some(b) = §ion.owner_write_blob { + visit( + STRUCT_TAG_OWNER_WRITE_BLOB, + None, + &b.ciphertext, + &b.signature, + )?; + } + for b in §ion.grant_blobs { + visit( + STRUCT_TAG_GRANT_BLOB, + Some(b.tag), + &b.ciphertext, + &b.signature, + )?; + } + for l in §ion.history_links { + visit(STRUCT_TAG_HISTORY_LINK, None, &l.sealed, &l.signature)?; + } + let body = §ion.write_body; + visit(STRUCT_TAG_WRITE_BODY, None, &body.sealed, &body.signature)?; + if let Some(a) = §ion.ascent_link { + visit(STRUCT_TAG_ASCENT_LINK, None, &a.ciphertext, &a.signature)?; + } + Ok(()) +} + /// The gate's stage-3 predicate: authenticate every structure signature /// `section` carries against **one** of the pseudonyms its own commitment names, /// recomputed at `envelope`'s scope and epoch — whatever epoch a structure's own /// sealed AAD binds (blueprint/core.md "Structure signatures"). The single /// signer is pinned by the first structure ([`StructureAuthenticator`]). /// +/// Stage 3 only: the pseudonyms come from the section's own commitment, which +/// [`verify_grant_set`] anchors to the owner identity at stage 2. +/// /// Also run release-active on the produce side (`net/author.rs`), so a scope /// root this build's own gate would reject is never signed (AGENTS.md rule 8). +#[doc(hidden)] pub fn authenticate_section_structures( section: &GrantSection, envelope: &Envelope, @@ -397,49 +451,9 @@ pub fn authenticate_section_structures( committed: committed_write_pseudonyms(§ion.commitment), pinned: None, }; - let mut authenticate = |tag: u8, recipient: Option<[u8; 32]>, ct: &[u8], sig: &[u8; 64]| { + for_each_structure(section, |tag, recipient, ct, sig| { auth.authenticate(scope, epoch, tag, recipient, ct, sig) - }; - authenticate( - STRUCT_TAG_OWNER_BLOB, - None, - §ion.owner_blob.ciphertext, - §ion.owner_blob.signature, - )?; - if let Some(owner_write) = §ion.owner_write_blob { - authenticate( - STRUCT_TAG_OWNER_WRITE_BLOB, - None, - &owner_write.ciphertext, - &owner_write.signature, - )?; - } - for blob in §ion.grant_blobs { - authenticate( - STRUCT_TAG_GRANT_BLOB, - Some(blob.tag), - &blob.ciphertext, - &blob.signature, - )?; - } - for link in §ion.history_links { - authenticate(STRUCT_TAG_HISTORY_LINK, None, &link.sealed, &link.signature)?; - } - authenticate( - STRUCT_TAG_WRITE_BODY, - None, - §ion.write_body.sealed, - §ion.write_body.signature, - )?; - if let Some(ascent) = §ion.ascent_link { - authenticate( - STRUCT_TAG_ASCENT_LINK, - None, - &ascent.ciphertext, - &ascent.signature, - )?; - } - Ok(()) + }) } impl SeedBlob<'_> { @@ -759,7 +773,10 @@ mod tests { fn authenticator(signers: &[Ed25519Signer]) -> StructureAuthenticator { StructureAuthenticator { - committed: signers.iter().map(|s| s.verifying_key()).collect(), + committed: signers + .iter() + .map(|s| s.verifying_key().to_bytes()) + .collect(), pinned: None, } } @@ -808,11 +825,6 @@ mod tests { #[test] fn a_section_signed_by_two_committed_pseudonyms_is_unadoptable() { - // The residual amplification: before pinning, every structure was - // trial-verified against the whole committed set, so an attacker who - // spread a section's signatures across many committed pseudonyms forced - // the full `pseudonyms x structures` product. A section is one rotator's - // work, so the second signer is a trust violation. let signers = committed_signers(); let mut auth = authenticator(&signers); authenticate(&mut auth, b"first", &signed(&signers[0], b"first")).expect("pins signer 0"); @@ -825,6 +837,19 @@ mod tests { ); } + #[test] + fn a_commitment_naming_no_usable_write_pseudonym_authenticates_nothing() { + // Zero candidates: nothing can pin, so nothing adopts. + let signer = Ed25519Signer::from_seed([1; 32]); + let mut auth = authenticator(&[]); + assert_eq!( + authenticate(&mut auth, b"first", &signed(&signer, b"first")) + .unwrap_err() + .check(), + "structure-signature-invalid" + ); + } + #[test] fn a_signature_from_no_committed_pseudonym_is_rejected_pinned_or_not() { let signers = committed_signers(); diff --git a/crates/engine/src/gate/mod.rs b/crates/engine/src/gate/mod.rs index 58359ed37..e0d81763d 100644 --- a/crates/engine/src/gate/mod.rs +++ b/crates/engine/src/gate/mod.rs @@ -20,5 +20,9 @@ mod adoption; pub use adoption::{ Adopted, Candidate, FLOOR_VERDICTS, GateError, GateRejection, GateStage, PendingAdoption, ReaderContext, RejectionReason, SeedBlob, adopt, adopt_deferred, - authenticate_section_structures, +}; +// `#[doc(hidden)]` at their definitions: `pub` only for the gate KAT generator +// and suite, which are external crates. Stage 3 alone is a partial verdict. +pub use adoption::{ + authenticate_section_structures, committed_write_pseudonyms, for_each_structure, }; diff --git a/crates/engine/src/net/author.rs b/crates/engine/src/net/author.rs index cee1e4013..3655d193a 100644 --- a/crates/engine/src/net/author.rs +++ b/crates/engine/src/net/author.rs @@ -543,10 +543,9 @@ mod tests { #[test] fn a_scope_root_envelope_whose_section_has_two_signers_is_refused() { - // Release-active (security rule 8): the gate pins a section to the - // pseudonym its first structure verified under, so a section spread - // across two *committed* pseudonyms is unadoptable and must never be - // signed. Both signers are committed here, so only the pin rejects it. + // Release-active (security rule 8). Both signers are committed and the + // commitment is re-signed, so stages 2 and 3's other checks pass and + // only the section-signer pin can reject this. let fixture = owner_root(); let second = Ed25519Signer::from_seed([0x55; 32]); let mut section = fixture.grant_section.clone(); diff --git a/crates/engine/tests/kat_gate.rs b/crates/engine/tests/kat_gate.rs index c3af74cf9..e27102737 100644 --- a/crates/engine/tests/kat_gate.rs +++ b/crates/engine/tests/kat_gate.rs @@ -1,19 +1,21 @@ //! The adoption gate's frozen stage-3 vectors: **one section, one signer** -//! (blueprint/engine.md "Adoption gate and floors"). -//! -//! Sibling of the content-DAG suite and self-contained — its own manifest under -//! `kat/gate/`, since the gate freezes a trust predicate over whole scope-root -//! head blocks rather than a content format. `crates/engine/kat` is written only -//! by `cargo run -p cipherbox-engine --example kat_gen`; CI diffs the -//! regenerated tree, so a verdict change that is not a deliberate re-freeze -//! fails there. +//! (blueprint/engine.md "Adoption gate and floors"). Sibling of the content-DAG +//! suite, with its own manifest under `kat/gate/` because it freezes a trust +//! predicate over whole scope-root head blocks rather than a content format. use std::collections::BTreeSet; +use std::convert::Infallible; use cipherbox_core::error::TrustViolation; -use cipherbox_core::seal::{decode_envelope, decode_grant_section, grant_section_bytes}; +use cipherbox_core::seal::{ + Envelope, GrantSection, Permission, StructureSigInput, decode_envelope, decode_grant_section, + grant_section_bytes, verify_grant_set, verify_structure, +}; use cipherbox_core::suite::ecdsa::{EcdsaSignature, EcdsaVerifier}; -use cipherbox_engine::gate::authenticate_section_structures; +use cipherbox_core::suite::ed25519::{Ed25519Signature, Ed25519Verifier}; +use cipherbox_engine::gate::{ + authenticate_section_structures, committed_write_pseudonyms, for_each_structure, +}; use serde::Deserialize; const MANIFEST: &str = include_str!("../kat/gate/manifest.json"); @@ -83,10 +85,10 @@ fn bytes(hex_str: &str) -> Vec { hex::decode(hex_str).expect("vector hex decodes") } -/// Run the vector's head block through the gate's stage 2 and stage 3, in the -/// gate's own order. Stage 2 must pass on every vector — otherwise a stage-3 -/// vector would be proving nothing but a bad commitment signature. -fn authenticate(v: &SectionSignerVector) -> Result<(), cipherbox_core::error::CodecError> { +/// The vector's decoded head block, after asserting the gate's **stage 2** +/// passes: a stage-3 vector whose commitment signature is bad would prove +/// nothing about stage 3. +fn stage_two(v: &SectionSignerVector) -> (Envelope, GrantSection) { let envelope = decode_envelope(&bytes(&v.head_block)).expect("head block decodes"); let section = decode_grant_section( grant_section_bytes(&envelope).expect("a scope root carries its grant section"), @@ -95,10 +97,47 @@ fn authenticate(v: &SectionSignerVector) -> Result<(), cipherbox_core::error::Co let owner = EcdsaVerifier::from_sec1(&bytes(&v.owner_identity_pk)).expect("owner identity"); let sig = EcdsaSignature::from_compact(§ion.commitment_sig).expect("commitment signature"); - cipherbox_core::seal::verify_grant_set(&owner, §ion.commitment, &sig) + verify_grant_set(&owner, §ion.commitment, &sig) .expect("every vector passes stage 2, so stage 3 owns the verdict"); + (envelope, section) +} - authenticate_section_structures(§ion, &envelope) +/// Stage 3's **pre-pin** predicate: for each seed-bearing structure, every +/// committed write-capable pseudonym whose key verifies it. Driven off the +/// gate's own [`for_each_structure`] and [`committed_write_pseudonyms`], so a +/// new structure kind or a change to the committed set cannot leave this +/// harness describing a section the gate no longer reads the same way. +/// +/// The pin makes stage 3 stop at the first signer, so only this wider view can +/// show that a reject vector's every signature is individually valid — that the +/// pin, and nothing else, is what refuses it. +fn signers_per_structure(section: &GrantSection, envelope: &Envelope) -> Vec> { + let committed = committed_write_pseudonyms(§ion.commitment); + let mut out = Vec::new(); + let walked: Result<(), Infallible> = + for_each_structure(section, |tag, recipient, ct, signature| { + let input = StructureSigInput::over_ciphertext( + envelope.scope, + envelope.epoch, + tag, + recipient, + ct, + ); + let sig = Ed25519Signature::from_bytes(*signature); + out.push( + committed + .iter() + .filter(|pk| { + Ed25519Verifier::from_bytes(**pk) + .is_some_and(|v| verify_structure(&v, &input, &sig).is_ok()) + }) + .copied() + .collect(), + ); + Ok(()) + }); + walked.expect("the walk never fails"); + out } #[test] @@ -137,11 +176,39 @@ fn accept_vectors_authenticate_under_one_committed_signer() { let m = manifest(); let vs = vectors(&m.section_signer_accept.file); let mut names = BTreeSet::new(); + let mut signed_by_a_non_owner = false; for v in &vs { assert!(names.insert(v.name.clone()), "duplicate vector {}", v.name); assert!(v.check.is_none() && v.class.is_none(), "{}", v.name); - authenticate(v).unwrap_or_else(|e| panic!("{}: {e}", v.name)); + let (envelope, section) = stage_two(v); + authenticate_section_structures(§ion, &envelope) + .unwrap_or_else(|e| panic!("{}: {e}", v.name)); + + // Non-vacuous: more than one committed write-capable pseudonym is on + // offer, and exactly one of them signed the whole section. + assert!( + section + .commitment + .entries + .iter() + .any(|e| e.permission == Permission::Write), + "{}: a one-pseudonym commitment pins vacuously", + v.name + ); + let signers: BTreeSet<[u8; 32]> = signers_per_structure(§ion, &envelope) + .into_iter() + .flatten() + .collect(); + let [signer] = signers.into_iter().collect::>()[..] else { + panic!("{}: one section, one signer", v.name); + }; + signed_by_a_non_owner |= signer != section.commitment.owner_pseudonym_pk; } + assert!( + signed_by_a_non_owner, + "pinning must not narrow *who* may sign: one accept vector is signed \ + throughout by a committed pseudonym that is not the owner's" + ); } #[test] @@ -152,24 +219,38 @@ fn a_section_signed_by_two_committed_pseudonyms_fails_closed() { let mut seen = BTreeSet::new(); for v in &vs { assert!(names.insert(v.name.clone()), "duplicate vector {}", v.name); - let error = authenticate(v).expect_err(&format!("{} must fail closed", v.name)); + let (envelope, section) = stage_two(v); + let error = authenticate_section_structures(§ion, &envelope) + .expect_err(&format!("{} must fail closed", v.name)); assert_eq!(Some(error.check()), v.check.as_deref(), "{}", v.name); assert_eq!(Some(error.class()), v.class.as_deref(), "{}", v.name); seen.insert(error.check().to_string()); + + // The pin, and nothing else, is what refuses these: every structure + // signature is individually valid under some committed pseudonym, and + // together they name more than one. + let per_structure = signers_per_structure(§ion, &envelope); + for (i, signers) in per_structure.iter().enumerate() { + assert_eq!( + signers.len(), + 1, + "{}: structure {i} must verify under exactly one committed pseudonym", + v.name + ); + } + let distinct: BTreeSet<[u8; 32]> = per_structure.into_iter().flatten().collect(); + assert_eq!( + distinct.len(), + 2, + "{}: a pin vector must carry exactly two committed signers", + v.name + ); } assert_eq!( - seen.into_iter().collect::>(), - { - let mut c = m.section_signer_reject.checks.clone(); - c.sort(); - c - }, + seen, + m.section_signer_reject.checks.iter().cloned().collect(), "the manifest's check list is exactly what the vectors fire" ); - assert!( - vs.iter().any(|v| v.name == "two-committed-signers"), - "the mixed-signer vector is the whole point of this family" - ); } #[test] From 344af806f0e21e22496665405f307415d8efdb30 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 6 Aug 2026 23:05:30 +0200 Subject: [PATCH 3/3] docs(engine): fix the garbled gate-KAT rationale sentence Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2 Entire-Checkpoint: 21bafcd6f524 --- crates/engine/examples/kat_gen.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/engine/examples/kat_gen.rs b/crates/engine/examples/kat_gen.rs index e4476c342..1e995d079 100644 --- a/crates/engine/examples/kat_gen.rs +++ b/crates/engine/examples/kat_gen.rs @@ -304,11 +304,10 @@ const GATE_KAT_GRANTEE_PSEUDONYM_SEED: [u8; 32] = [0x55; 32]; /// Stage 3's **one section, one signer** rule frozen over whole scope-root head /// blocks (blueprint/engine.md "Adoption gate and floors"). /// -/// Every vector shares one commitment naming two write-capable pseudonyms, so -/// the accept family pins that pinning narrows how many signers a section may -/// have and not which pseudonym may sign it, and every reject carries structure -/// signatures that are each individually valid under a committed pseudonym — -/// only the pin refuses them. +/// Every vector shares one commitment naming two write-capable pseudonyms: the +/// accept family shows the pin bounds how many signers a section has, not which +/// pseudonym may sign, and every reject's structure signatures are each valid +/// under a committed pseudonym, so only the pin refuses them. fn build_section_signer_vectors() -> (Vec, Vec) { let owner_identity = EcdsaSigner::from_scalar(&[0x11; 32]).expect("valid scalar"); let owner_enc = kdf::enc_subkey(&GATE_KAT_OWNER_ENC_SEED).public();