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..fdaf4a59d 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'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 d33532f84..1a68cf65e 100644 --- a/blueprint/engine.md +++ b/blueprint/engine.md @@ -163,18 +163,44 @@ 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. + +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 rejected record. Duplicate `id`s and duplicate `ipnsName`s within a scope diff --git a/blueprint/testing.md b/blueprint/testing.md index 2fc6bcd88..d06780f1f 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 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 The sdk-e2e descendant (#28 D6), and it inherits sdk-e2e's most valuable v1 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 72c7167d1..1e995d079 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,28 @@ 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::{ + 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; +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 +108,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 +159,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"); @@ -169,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(), @@ -178,16 +219,52 @@ 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()]), }, }, }; 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: checks_in_surface_order( + TrustViolation::CHECKS, + 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() ); } @@ -197,23 +274,172 @@ 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 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: 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(); + let fixture = owner_root_fixture(OwnerRootSpec { + owner_identity: &owner_identity, + owner_enc: &owner_enc, + 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() + }; + + // 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, + &grantee_signed.owner_blob.ciphertext, + ); + { + 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, + ); + + // 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(), + }); + + 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, + 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. 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..54a5befeb --- /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": 2 + }, + "sectionSignerReject": { + "file": "vectors/section_signer_reject.json", + "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 new file mode 100644 index 000000000..15f52e6ee --- /dev/null +++ b/crates/engine/kat/gate/vectors/section_signer_accept.json @@ -0,0 +1,12 @@ +[ + { + "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 new file mode 100644 index 000000000..b52cb1ed0 --- /dev/null +++ b/crates/engine/kat/gate/vectors/section_signer_reject.json @@ -0,0 +1,16 @@ +[ + { + "name": "two-committed-signers", + "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 162c9a1b3..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,17 +328,17 @@ fn committed_write_pseudonyms(commitment: &GrantSetCommitment) -> Vec, - resume_at: usize, + committed: Vec<[u8; 32]>, + 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,18 @@ 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(pinned) = &self.pinned { + return verify_structure(pinned, &input, &sig); + } + // 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(()); } } @@ -371,67 +386,76 @@ impl StructureAuthenticator { } } -/// The gate's stage-3 predicate: authenticate every structure signature -/// `section` carries against 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"). -/// -/// 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). -pub fn authenticate_section_structures( +/// 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, - envelope: &Envelope, -) -> Result<(), CodecError> { - let (scope, epoch) = (envelope.scope, envelope.epoch); - let mut auth = StructureAuthenticator { - committed: committed_write_pseudonyms(§ion.commitment), - resume_at: 0, - }; - let mut authenticate = |tag: u8, recipient: Option<[u8; 32]>, ct: &[u8], sig: &[u8; 64]| { - auth.authenticate(scope, epoch, tag, recipient, ct, sig) - }; - authenticate( + 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, - §ion.owner_blob.ciphertext, - §ion.owner_blob.signature, + &owner.ciphertext, + &owner.signature, )?; - if let Some(owner_write) = §ion.owner_write_blob { - authenticate( + if let Some(b) = §ion.owner_write_blob { + visit( STRUCT_TAG_OWNER_WRITE_BLOB, None, - &owner_write.ciphertext, - &owner_write.signature, + &b.ciphertext, + &b.signature, )?; } - for blob in §ion.grant_blobs { - authenticate( + for b in §ion.grant_blobs { + visit( STRUCT_TAG_GRANT_BLOB, - Some(blob.tag), - &blob.ciphertext, - &blob.signature, + Some(b.tag), + &b.ciphertext, + &b.signature, )?; } - for link in §ion.history_links { - authenticate(STRUCT_TAG_HISTORY_LINK, None, &link.sealed, &link.signature)?; + for l in §ion.history_links { + visit(STRUCT_TAG_HISTORY_LINK, None, &l.sealed, &l.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, - )?; + 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, +) -> Result<(), CodecError> { + let (scope, epoch) = (envelope.scope, envelope.epoch); + let mut auth = StructureAuthenticator { + committed: committed_write_pseudonyms(§ion.commitment), + pinned: None, + }; + for_each_structure(section, |tag, recipient, ct, sig| { + auth.authenticate(scope, epoch, tag, recipient, ct, sig) + }) +} + impl SeedBlob<'_> { /// The structured AAD the blob claims to be sealed under — cross-checked /// against the envelope before it is trusted. @@ -738,54 +762,108 @@ 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 { - committed: signers.iter().map(|s| s.verifying_key()).collect(), - resume_at: 0, - }; + .collect() + } + + fn authenticator(signers: &[Ed25519Signer]) -> StructureAuthenticator { + StructureAuthenticator { + committed: signers + .iter() + .map(|s| s.verifying_key().to_bytes()) + .collect(), + 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() { + 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(), + 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_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(); + 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..e0d81763d 100644 --- a/crates/engine/src/gate/mod.rs +++ b/crates/engine/src/gate/mod.rs @@ -17,8 +17,12 @@ 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, }; +// `#[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 5e441aff7..3655d193a 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,44 @@ mod tests { ); } + #[test] + fn a_scope_root_envelope_whose_section_has_two_signers_is_refused() { + // 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(); + 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..e27102737 --- /dev/null +++ b/crates/engine/tests/kat_gate.rs @@ -0,0 +1,267 @@ +//! 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, 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::{ + 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_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"); + +/// 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") +} + +/// 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"), + ) + .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"); + verify_grant_set(&owner, §ion.commitment, &sig) + .expect("every vector passes stage 2, so stage 3 owns the verdict"); + (envelope, section) +} + +/// 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] +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(); + 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); + 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] +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 (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, + m.section_signer_reject.checks.iter().cloned().collect(), + "the manifest's check list is exactly what the vectors fire" + ); +} + +#[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" + ); + } +}