diff --git a/CLAUDE.md b/CLAUDE.md index 7a8652e..38b07ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ See [`docs/DEV_HYGIENE.md`](docs/DEV_HYGIENE.md) for the layered self-cleaning p ## Current Implementation Status -*(Current release: **13.0.0** — see [Release history](#release-history) for this and every prior entry.)* +*(Current release: **13.1.0** — see [Release history](#release-history) for this and every prior entry.)* | Crate | Status | Notes | |-------|--------|-------| @@ -258,6 +258,7 @@ Newest first, **one release per line** — so adding a release is a one-line dif rather than a rewrite of the whole history (it was previously a single 98,000-character line, which made every release note unreviewable in `git diff`). +- **13.1.0** (SECURITY) — **"who is this ABOUT?" — the subject-blindness class, found on three surfaces and closed (#252, CIRISPersist#660)**. Every authority gate answered *who signed?*; none answered *who is this about?* A quorum verifies m-of-n over `JCS(envelope)` and **nothing else**, so where the value naming the subject lives OUTSIDE those bytes and is never compared, **the same valid signature applies to any subject**. A systematic sweep for the shape — a struct carrying an opaque signed `Value` alongside decision-driving sibling fields — found **six**; two were already safe (siblings are signatures only), one already read its subject from inside the signed bytes (`operational_admit`, the right shape all along), and **three were exposed**. **(1) `provenance::ProvenanceLink`** — the reported one. Mallory wraps a victim's genuine, validly-signed registration envelope in a link declaring her own `key_id` and pubkeys: content hash matches (it IS the victim's envelope), both scrub-signatures verify (really signed by the real parent), linkage passes — and the chain roots **the attacker's key**. **(2) `transport_binding::TransportBinding`** — worse, because it is a redirect primitive: `attesting_key_id`, `transport_destination` and `encryption_pubkeys` are siblings while the producer signs all three inside the occurrence envelope. Mallory KEEPS the victim's `attesting_key_id` so the signature still verifies, and substitutes **her own transport address**; key separation and the dest-hash recompute then both run against her address and agree, and the verdict reports the victim's identity as reachable at Mallory's address. The `encryption_pubkeys` variant aims the same trick at content encryption. **(3) `manifest_contribution` co-scrub blessing** — privilege transfer: the identity check read the sibling `key_id` while the authority evidence (`roles_in_envelope()` + the ≥2-anchor scrubs) came from the envelope, so a **genuinely** co-scrubbed record for a key that WAS blessed with `infra:attest`, relabelled on the outside only, blesses a key that never was — out of entirely valid signatures. New `subject_binding` implements persist's four hard-won rules: **bind the identity, not just the name** (both key legs, since binding `key_id` alone loses to a node that has not replicated the victim's row, where an attacker registers the victim's `key_id` under their own pubkeys); **the checker ITERATES the projection** so a member added later is enforced with no second edit; **fail CLOSED on absence** (*an optional check is skippable by omission, which is the whole attack*) with exactly one tolerated case — an expected `null` satisfied by omission, per CEG §0.9 omit-vs-materialize, and only when the carrier claims nothing; and **check the binding FIRST**, before anchor, roster or custody resolution. **The fixes are verifier-only — no wire change** — because verify's own producers already put the binding in the signed bytes; nothing had opened the letter. Proven by `real_producer_record_roundtrips_through_verifier` passing untouched throughout. **Five test fixtures had to be rebuilt, and that is the finding under the finding:** each mutated a field OUTSIDE the signed envelope to simulate tampering — which is now precisely the refused attack — so they were asserting downstream errors while handing the verifier incoherent objects. The provenance fixture also invented its own member names (`pubkey_ed25519` for `pubkey_ed25519_base64`), the same producer/fixture divergence that let the v10.4.0 preimage bug ship green. 3 attack tests (lifted envelope, substituted destination, relabelled blessing) + 8 primitive tests; 954 core green, 1348 workspace, clippy clean. - **13.0.0** (BREAKING) — **the arity ruling adopted: a frame is refused when undeclared, and `binding` splits into its two dispositions (#248, CIRISOntology#3/#1)**. CIRISOntology proved two things in Lean that verify's classification surface was quietly getting wrong. **(1) `testimonial` is a RELATION, not a property.** The discriminator between an epistemic wrong (misreports a world still available to re-read) and a testimonial one (corrupts the only record) is *re-derivability* — and `repairability_not_intrinsic` exhibits **one fact, two frames, opposite verdicts**, so `repairable_does_not_factor` concludes no artifact-only procedure can assign it *by any procedure whatsoever*. New `Frame` + `Arity`: `Arity::testimonial(subject, frame)` **refuses an undeclared frame rather than defaulting one**, because a defaulted frame is precisely the unstated assumption that silently decides the verdict. The frame belongs to the **harness, declared once in the open** — `self_declared_frame_undetermined` shows that pushing it into the blocks just moves the free parameter — so **verify supplies no default frame and must not**: verify is a library, and what is retained is a property of the deployment. **(2) "binding" was two words wearing one.** `binding_never_varies` holds for deontic/structural/testimonial while `axiomatic_binds_by_varying` exhibits a class that is binding *and* varies, so a reader who infers "held" from "binding" is wrong — and one who cannot tell *this would break* from *this is disallowed* petitions the wrong body. `Gating::Normative { authority }` (held under a ruling) now splits from **`Gating::Structural { breaks }`** (cannot vary; the machine breaks), with `amendable_by()` naming who can change it and returning **`None`** where nobody can. **The split is load-bearing, not theoretical, because verify ships one of each:** `ConsentDisposition` stays `Normative(CC 3.4.5)` — CC could rule otherwise tomorrow and nothing mechanically breaks — while **`Purpose` moves to `Structural`**, since its values are pinned CDDL wire indices and deviating breaks CBOR dispatch against every other CoTS implementation, which no body can waive. A ruling composed with a mechanism degrades to `Measurement`: the composite can claim neither ground without implying either that a body may waive a mechanical constraint or that a ruling is unamendable. **(3) Dimension re-audit under the frame rule.** Exactly one of CC 3.4.5's four categories is artifact-only — `ArtifactVerification`, and its carve-out rationale *is* re-readability (*"a forger never consents to verification"* works because the artifact needs no cooperation to be re-read). `SelfReport` / `LogInfrastructure` / `AbuseResponse` are frame-relative and now refuse without a declared frame. **The #233/CC#83 disposition guard caught the `Purpose` change and forced it to be declared** rather than slipped in — the coverage test doing exactly the job it was added for one release earlier. 13 new tests mirroring the Lean theorems (not restating their conclusions); 945 core green, clippy clean. - **12.6.0** — **the TPM slot is populated — 40 vendor roots, at the tier their sourcing actually earned (#199, #227)**. v12.3.0 held TPM unbaked on CIRISPersist's rule that *zero anchors we can defend beats six we cannot* — and then built the machinery that makes them defensible. `AnchorProvenance` is exactly what lets an honestly-labeled aggregation ship beside a vendor-official root without overstating either, so the blocker was never the certificates; it was not having a way to say where they came from. **The provenance work is the deliverable, and it is written down** (`docs/TPM_ANCHOR_PROVENANCE.md`): four sourcing routes attempted, three dead ends recorded rather than quietly skipped. **Vendor-official endpoints do not exist for TPM** — Infineon/Nuvoton serve JS-driven pages with *zero* PEM blocks, ST was unreachable, Intel direct returns 403; there is no `android.googleapis.com/attestation/root` analogue because EK roots are distributed to OS integrators, not published. **Correction to the standing record:** [`1id-com/tpm-manufacturer-cas`](https://github.com/1id-com/tpm-manufacturer-cas), referred to in prior CIRIS discussion as a usable bundle, contains **zero certificates** — the bundle is a placeholder comment, every `certs/*/` holds only a README, and its `update-intel.sh` never fetched anything. It is a scaffold; nobody should reason about TPM anchor availability from its existence. What worked was Microsoft's **`TrustedTPM.cab`** (2,988,320 B, upstream-modified 2026-07-21, 2,571 files) — parsed CFHEADER/CFFILE and decompressed the MSZIP folder block-by-block in-process, since no CAB extractor exists on the box. From 2,567 certificates: Microsoft's own 2,184 excluded, **every intermediate excluded** (pinning a rotating issuer as an anchor is the mistake the Yubico 2024-12 PKI overhaul stands as permanent proof against), self-signatures **cryptographically verified rather than name-matched** (40 of 41 passed), and **one Infineon root excluded for expiring in 2018** — its signature is sound, expiry alone disqualifies it. **Tier is `CommunityAggregated`, not `VendorOfficial`, and that is load-bearing:** Microsoft curates but is a *third party* to Infineon/Nuvoton/ST/Atmel/NationZ/Qualcomm, and nothing here was cross-confirmed against the vendors the way both Google roots were against two independent Google sources. A caller demanding first-party sourcing gets **nothing** from `resolve_x509_min_provenance(…, VendorOfficial)` — asserted by test, so the tier cannot quietly stop meaning something. **The set design is vindicated far harder than by Google's two roots: Nuvoton alone ships 17** (40 across 8 vendors), which a `pinned_root` parameter could not have expressed for that vendor at all. A few anchors are third-party CAs filed under a vendor (VeriSign/Infineon, GlobalSign/ST, a Microsoft root/Qualcomm) — not an error; those vendors genuinely chain EK certs there, and dropping them would fail real hardware. Ten roots carry SHA-1 self-signatures, recorded so it is never mistaken for a downgrade: these are trusted by **pinned DER digest**, not by their self-signature. Framing unchanged — absence is *no hardware evidence*, never a refusal; no revocation is consulted; no discrete-vs-firmware split is invented. **A stale test was caught by the bake:** `baked_store_does_not_expose_yubico_as_a_tpm_anchor` asserted the TPM slot was *empty*, which was true only while TPM was unpopulated — the exact shape flagged in v10.11.0 of a test that silently stops testing anything. It now asserts **fingerprint identity** across the populated set. 6 new/rewritten tests; 935 core green, clippy clean. - **12.5.0** — **the CC#80 interim MUST is enforced, not merely satisfied by construction**. CC ruled unlinkability **required as a property commitment, staged as an adoption path** (OR-of-N portability now, unlinkable slot reserved, ZK-wrapped *unmodified* ML-DSA the designated candidate), carrying one interim MUST: *no presentation surface may bake a mandatory stable identifier across verifiers into the wire — **the upgrade must remain a format addition, never a re-issuance event***. New `presentation` module reads that precisely: it is **not** "no global identifiers today" (verify's envelopes legitimately carry `attesting_key_id`; CC staged that deliberately) but a constraint on **entanglement** — identity must stay separable from format, because if an unlinkable presentation required minting a new identity then every issued FedID needs re-issuance, and a migration that expensive never happens, which is how a reserved slot quietly becomes unreachable. Audited inventory of the **person-presentation** surfaces (`self_at_login`): `attesting_key_id` / `identity_key_id` / `subject_key_ids` are **Global** (the correlation handles an unlinkable format must be able to omit), while `occurrence_key_id` / `org_id` / `bilateral_pair_id` are narrower and safe to carry. Node/artifact surfaces are **deliberately out of scope** and a test says so — CC's differential-uptake rationale is about parties who cannot manage key hygiene, and a build server is not one; "fixing" a build key_id into a pseudonym would break provenance for no safety gain. Two guards: a **drift check** that fails if a producer emits an unclassified identifier-shaped member (a silent narrowing of the reserved slot), and the MUST itself **exercised against the real producers** — one identity, two envelope formats, both key halves unchanged, since ZK-wrapping proves over those same signatures. 932 core green, clippy clean. diff --git a/Cargo.lock b/Cargo.lock index a3697ab..8569bab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,7 +668,7 @@ dependencies = [ [[package]] name = "ciris-build-tool" -version = "13.0.0" +version = "13.1.0" dependencies = [ "anyhow", "base64 0.21.7", @@ -690,7 +690,7 @@ dependencies = [ [[package]] name = "ciris-crypto" -version = "13.0.0" +version = "13.1.0" dependencies = [ "chacha20poly1305", "criterion", @@ -722,7 +722,7 @@ dependencies = [ [[package]] name = "ciris-keyring" -version = "13.0.0" +version = "13.1.0" dependencies = [ "aes-gcm", "async-trait", @@ -762,7 +762,7 @@ dependencies = [ [[package]] name = "ciris-manifest-tool" -version = "13.0.0" +version = "13.1.0" dependencies = [ "anyhow", "chrono", @@ -779,7 +779,7 @@ dependencies = [ [[package]] name = "ciris-tpm-plugin" -version = "13.0.0" +version = "13.1.0" dependencies = [ "sha2", "tracing", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "ciris-verify-core" -version = "13.0.0" +version = "13.1.0" dependencies = [ "android_system_properties", "async-trait", @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "ciris-verify-ffi" -version = "13.0.0" +version = "13.1.0" dependencies = [ "aes-gcm", "android_logger", diff --git a/Cargo.toml b/Cargo.toml index 65c898c..d866a0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "13.0.0" +version = "13.1.0" edition = "2021" rust-version = "1.86" license = "AGPL-3.0-or-later" diff --git a/bindings/python/ciris_verify/__init__.py b/bindings/python/ciris_verify/__init__.py index a72126c..416a5c9 100644 --- a/bindings/python/ciris_verify/__init__.py +++ b/bindings/python/ciris_verify/__init__.py @@ -169,7 +169,7 @@ def get_library_version() -> str: return __version__ -__version__ = "13.0.0" +__version__ = "13.1.0" __all__ = [ "CIRISVerify", "MockCIRISVerify", diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 6053c21..05ae43a 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ciris-verify" -version = "13.0.0" +version = "13.1.0" description = "Python bindings for CIRISVerify hardware-rooted license verification" readme = "README.md" license = "AGPL-3.0-or-later" diff --git a/evidence/cc_impl.tsv b/evidence/cc_impl.tsv index 20fb1af..fba1f63 100644 --- a/evidence/cc_impl.tsv +++ b/evidence/cc_impl.tsv @@ -65,3 +65,5 @@ decimal_id claim_id repo path#symbol crate@version 3.4.5 CLM-binding-disposition-split CIRISVerify src/ciris-verify-core/src/classification.rs#Gating ciris-verify-core@v13.0.0 2.6.1.1 CLM-redactable-commitment CIRISVerify src/ciris-verify-core/src/redactable.rs#RedactableCommitment ciris-verify-core@v13.0.0 5.1 CLM-presentation-identifier-scope CIRISVerify src/ciris-verify-core/src/presentation.rs#IdentifierScope ciris-verify-core@v13.0.0 +UNASSIGNED CLM-subject-binding CIRISVerify src/ciris-verify-core/src/subject_binding.rs#SubjectBinding ciris-verify-core@v13.1.0 +UNASSIGNED CLM-subject-binding-keyrecord CIRISVerify src/ciris-verify-core/src/federation_self_record.rs#check_subject_binding ciris-verify-core@v13.1.0 diff --git a/src/ciris-verify-core/src/federation_self_record.rs b/src/ciris-verify-core/src/federation_self_record.rs index dcea21e..eb22518 100644 --- a/src/ciris-verify-core/src/federation_self_record.rs +++ b/src/ciris-verify-core/src/federation_self_record.rs @@ -215,6 +215,36 @@ impl KeyRecord { .unwrap_or_default() } + /// **Bind this record's declared identity to its SIGNED envelope** + /// (CIRISVerify#252). + /// + /// `key_id`, `identity_type` and the pubkeys are sibling fields living + /// OUTSIDE `registration_envelope`, while the authority evidence — the + /// roles in [`Self::roles_in_envelope`] and the anchor scrub-signatures — + /// is verified *against the envelope*. Without this check the two halves + /// can describe different keys: a record whose sibling `key_id` names the + /// key you pinned, wrapped around a genuinely accord-co-scrubbed envelope + /// for some OTHER key that carries `infra:attest`, passes an identity + /// comparison, a role read, and a real ≥2-anchor quorum — and blesses a + /// key that was never blessed. + /// + /// Call this **before** trusting anything read out of the envelope. + /// + /// # Errors + /// [`SubjectBindingError`](crate::subject_binding::SubjectBindingError) if + /// the envelope is about a different subject, or carries no binding. + pub fn check_subject_binding(&self) -> Result<(), crate::subject_binding::SubjectBindingError> { + crate::subject_binding::SubjectBinding::new() + .require("key_id", self.key_id.clone()) + .require("identity_type", self.identity_type.clone()) + .require("pubkey_ed25519_base64", self.pubkey_ed25519_base64.clone()) + .require_optional( + "pubkey_ml_dsa_65_base64", + self.pubkey_ml_dsa_65_base64.as_deref(), + ) + .check("key record", &self.registration_envelope) + } + /// Read the **scrub-attested** roles carried in the signed /// `registration_envelope` (CIRISVerify#185). Unlike the top-level /// [`Self::roles`] (persist's *conferred* row state), this is the role set the diff --git a/src/ciris-verify-core/src/lib.rs b/src/ciris-verify-core/src/lib.rs index ac31930..784d2d8 100644 --- a/src/ciris-verify-core/src/lib.rs +++ b/src/ciris-verify-core/src/lib.rs @@ -131,6 +131,7 @@ pub mod security; pub mod self_at_login; pub mod skill_import; pub mod steward_key; +pub mod subject_binding; /// CIRISVerify#202 — compile-time-fenced test-only trust-root / custody /// relaxation for the local mesh harness (never in a prod artifact). pub mod test_anchor; diff --git a/src/ciris-verify-core/src/manifest_contribution.rs b/src/ciris-verify-core/src/manifest_contribution.rs index e787c3b..570cd54 100644 --- a/src/ciris-verify-core/src/manifest_contribution.rs +++ b/src/ciris-verify-core/src/manifest_contribution.rs @@ -162,6 +162,13 @@ pub struct VerifiedManifest { /// hard reject — there is no partial-trust path (fail-closed). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ManifestRejection { + /// **The pipeline `KeyRecord`'s signed envelope is about a different key + /// than the record declares** (CIRISVerify#252). The scrubs may be + /// entirely valid — they are simply not about this key. + PipelineRecordSubjectMismatch { + /// Which member disagreed, and how. + source: crate::subject_binding::SubjectBindingError, + }, /// The outbox object is not a `build_manifest_contribution`. WrongKind { /// The kind actually found. @@ -263,6 +270,9 @@ pub enum ManifestRejection { impl std::fmt::Display for ManifestRejection { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::PipelineRecordSubjectMismatch { source } => { + write!(f, "pipeline key record: {source}") + }, Self::WrongKind { kind } => { write!(f, "not a build-manifest contribution: kind {kind:?}") }, @@ -633,6 +643,17 @@ pub fn verify_build_manifest_via_coscrub( // --- 4. The pipeline key is BLESSED: its accord-co-scrubbed KeyRecord carries // infra:attest AND reaches the ≥2 distinct-anchor quorum. --- + // + // #252: bind the record's DECLARED identity to its SIGNED envelope FIRST. + // The identity comparison below reads the sibling `key_id`, while the + // authority evidence (roles, anchor scrubs) is verified against the + // envelope. Unbound, a record whose sibling names the pinned pipeline but + // whose envelope is a genuinely co-scrubbed record for a DIFFERENT key + // carrying `infra:attest` satisfies all three — blessing a key that was + // never blessed, out of entirely valid signatures. + pipeline_record + .check_subject_binding() + .map_err(|source| ManifestRejection::PipelineRecordSubjectMismatch { source })?; if pipeline_record.key_id != attesting_key_id { return Err(ManifestRejection::PipelineRecordMismatch { record: pipeline_record.key_id.clone(), @@ -1083,13 +1104,56 @@ mod tests { ); } + /// **CIRISVerify#252 on the blessing surface — privilege transfer, refused.** + /// + /// Mallory's key was never blessed. She takes a **genuinely** + /// accord-co-scrubbed record for a key that WAS blessed with + /// `infra:attest` — real envelope, real ≥2-anchor scrubs — and relabels + /// only the sibling `key_id` to the pipeline the verifier pins. + /// + /// Pre-#252 that satisfied all three gates: the identity comparison read + /// the sibling, `roles_in_envelope()` read the (genuine) envelope, and the + /// quorum verified the (genuine) scrubs. A key that was never blessed came + /// out blessed, from entirely valid signatures. + #[tokio::test] + async fn relabelled_record_over_a_genuine_blessed_envelope_is_refused() { + let a1 = HybridSigningIdentity::generate("A1").unwrap(); + let b1 = HybridSigningIdentity::generate("B1").unwrap(); + let (obj, pm, blessed, anchors) = + coscrub_setup(vec!["infra:attest".to_string()], &[&a1, &b1], &[&a1, &b1]).await; + + // The genuine record, relabelled on the OUTSIDE only. Envelope, + // content hash and both anchor scrubs are untouched and valid. + let mut lifted = blessed.clone(); + lifted.key_id = PIPELINE.to_string(); + lifted.registration_envelope["key_id"] = json!("victim-key-that-was-blessed"); + + // The authority evidence is still genuine … + assert!(lifted + .roles_in_envelope() + .iter() + .any(|r| r == MANIFEST_PUBLISH_SCOPE)); + + // … and it is refused anyway, on the subject. + let err = verify_build_manifest_via_coscrub(&obj, &pm, &lifted, &anchors).unwrap_err(); + assert!( + matches!(err, ManifestRejection::PipelineRecordSubjectMismatch { .. }), + "a relabelled record MUST be refused on the subject, got {err:?}" + ); + } + #[tokio::test] async fn pipeline_record_for_a_different_key_is_rejected() { let a1 = HybridSigningIdentity::generate("A1").unwrap(); let b1 = HybridSigningIdentity::generate("B1").unwrap(); let (obj, pm, mut rec, anchors) = coscrub_setup(vec!["infra:attest".to_string()], &[&a1, &b1], &[&a1, &b1]).await; + // Move BOTH halves, so the record is internally coherent and this + // exercises the key_id rule rather than the #252 binding. (Its scrubs + // no longer verify over the changed envelope, which is why the binding + // is checked first — see the companion test below.) rec.key_id = "some-other-node".to_string(); + rec.registration_envelope["key_id"] = json!("some-other-node"); let err = verify_build_manifest_via_coscrub(&obj, &pm, &rec, &anchors).unwrap_err(); assert_eq!( err, @@ -1128,7 +1192,10 @@ mod tests { let b1 = HybridSigningIdentity::generate("B1").unwrap(); let (obj, pm, mut rec, anchors) = coscrub_setup(vec!["infra:attest".to_string()], &[&a1, &b1], &[&a1, &b1]).await; - rec.registration_envelope["pubkey_ed25519_base64"] = json!("00".repeat(32)); + // Tamper a member that is NOT part of the subject binding, so the + // binding still holds and the *quorum* is what collapses — otherwise + // this would only re-test #252. + rec.registration_envelope["valid_from"] = json!("1999-01-01T00:00:00Z"); let err = verify_build_manifest_via_coscrub(&obj, &pm, &rec, &anchors).unwrap_err(); assert_eq!( err, diff --git a/src/ciris-verify-core/src/provenance.rs b/src/ciris-verify-core/src/provenance.rs index f40745e..32e67fa 100644 --- a/src/ciris-verify-core/src/provenance.rs +++ b/src/ciris-verify-core/src/provenance.rs @@ -134,6 +134,18 @@ pub struct ProvenanceChain { /// exactly one of these — no third state. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProvenanceError { + /// **The signed envelope is about a DIFFERENT subject than the link + /// claims** (CIRISVerify#252), or carries no binding at all. + /// + /// The signature may be perfectly valid — that is precisely the point. A + /// valid signature over someone else's registration says nothing about + /// this link. + SubjectBindingFailed { + /// The `key_id` the link claimed. + key_id: String, + /// Which member disagreed, and how. + source: crate::subject_binding::SubjectBindingError, + }, /// The chain has no links. EmptyChain, /// The chain is longer than [`MAX_PROVENANCE_DEPTH`]. @@ -220,6 +232,9 @@ pub enum ProvenanceError { impl std::fmt::Display for ProvenanceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::SubjectBindingFailed { key_id, source } => { + write!(f, "link {key_id}: {source}") + }, Self::EmptyChain => write!(f, "provenance chain is empty"), Self::OverDepth { depth } => { write!( @@ -385,6 +400,37 @@ pub fn verify_provenance_chain_with_policy_and_terminus( let last = links.len() - 1; for (i, link) in links.iter().enumerate() { + // ---- SUBJECT BINDING: "who is this ABOUT?" (#252) ---------------- + // Checked FIRST, before the hash, the signatures, and any anchor or + // terminus resolution, so the refusal is deterministic regardless of + // that state (rule 4). + // + // Without this, the link's identity fields live OUTSIDE the signed + // bytes: an attacker wraps a victim's genuine, validly-signed envelope + // in a link declaring their own key_id and pubkeys. The content hash + // matches (it really is the victim's envelope), the signatures verify + // (really signed by the real parent), the linkage passes — and the + // chain roots the ATTACKER's key. The binding was in the signed bytes + // the whole time; nothing read it. + // + // Both key legs are bound, not just `key_id` (rule 1): binding the + // name alone loses to a node that has not yet replicated the victim's + // row, where an attacker registers the victim's `key_id` under their + // own pubkeys. + crate::subject_binding::SubjectBinding::new() + .require("key_id", link.key_id.clone()) + .require("identity_type", link.identity_type.clone()) + .require("pubkey_ed25519_base64", link.pubkey_ed25519_base64.clone()) + .require_optional( + "pubkey_ml_dsa_65_base64", + link.pubkey_ml_dsa_65_base64.as_deref(), + ) + .check("provenance link", &link.registration_envelope) + .map_err(|source| ProvenanceError::SubjectBindingFailed { + key_id: link.key_id.clone(), + source, + })?; + // ---- structural: linkage + terminus shape ----------------------- let parent: &ProvenanceLink = if i == last { if !link.is_self_signed || link.scrub_key_id != link.key_id { @@ -565,12 +611,18 @@ mod tests { with_pqc: bool, ) -> ProvenanceLink { let b64 = base64::engine::general_purpose::STANDARD; - // A representative registration envelope (shape is opaque to the - // verifier — it canonicalizes whatever object is here). + // The registration envelope in the shape the REAL producers emit + // (`federation_self_record::build_registration_envelope`). The shape is + // NOT opaque to the verifier any more: since #252 it carries the + // subject binding the verifier checks, so a fixture that invents its + // own member names would pass the crypto and skip the binding — which + // is exactly how the v10.4.0 preimage bug hid (cargo-green, + // artifact-broken). `salt` distinguishes links' signed bytes. let registration_envelope = serde_json::json!({ "key_id": key_id, "identity_type": identity_type, - "pubkey_ed25519": own.ed_pub_b64(), + "pubkey_ed25519_base64": own.ed_pub_b64(), + "pubkey_ml_dsa_65_base64": own.mldsa_pub_b64(), "salt": salt, }); let canonical = crate::jcs::canonicalize(®istration_envelope).unwrap(); @@ -601,6 +653,46 @@ mod tests { /// A valid 2-link chain: child ← steward(self-signed). Returns the /// chain and the steward's pinned Ed25519 anchor key. + /// Rebuild the terminus with a different `identity_type`, keeping the + /// SIGNED ENVELOPE consistent with the link's declared fields. + /// + /// Since #252, mutating `link.identity_type` alone is a subject-binding + /// violation — correctly, because `identity_type` is authority-bearing: it + /// decides whether the terminus is an acceptable root. A test that wants + /// to exercise the *terminus* rule must therefore hand the verifier a + /// coherent object, or it is only re-testing the binding. + fn chain_with_terminus_type(identity_type: &str) -> (ProvenanceChain, Vec) { + let steward = Keypair::new(); + let child = Keypair::new(); + let steward_link = make_link( + "steward-1", + identity_type, + &steward, + &steward, + "steward-1", + 0xAA, + true, + ); + let child_link = make_link( + "agent-1", + "agent", + &child, + &steward, + "steward-1", + 0xBB, + true, + ); + let anchor = steward.ed_pub(); + ( + ProvenanceChain { + key_id: "agent-1".to_string(), + chain: vec![child_link, steward_link], + terminates_at_steward_bootstrap: true, + }, + anchor, + ) + } + fn valid_chain() -> (ProvenanceChain, Vec, Keypair, Keypair) { let steward = Keypair::new(); let child = Keypair::new(); @@ -950,10 +1042,111 @@ mod tests { ); } + /// **CIRISVerify#252 — the attack, refused.** + /// + /// Mallory takes a victim's genuine, validly-signed registration envelope + /// (public data) and wraps it in a link declaring HER OWN key_id and + /// pubkeys. Everything the pre-#252 verifier checked still passes: + /// + /// * `original_content_hash` matches — it really is the victim's envelope + /// * both scrub-signatures verify — really signed by the real parent + /// * the linkage names the real parent + /// + /// Pre-fix the chain rooted and the caller read Mallory's key_id off the + /// link. The binding is what refuses it. #[test] - fn terminus_not_steward_is_rejected() { + fn lifted_envelope_under_an_attacker_identity_is_refused() { + let (chain, anchor, _child, steward) = valid_chain(); + let victim = chain.chain[0].clone(); + let mallory = Keypair::new(); + + let mut lifted = victim.clone(); + lifted.key_id = "mallory".to_string(); + lifted.pubkey_ed25519_base64 = mallory.ed_pub_b64(); + lifted.pubkey_ml_dsa_65_base64 = Some(mallory.mldsa_pub_b64()); + // Envelope, hash and signatures are the victim's, untouched and valid. + + let attack = ProvenanceChain { + key_id: "mallory".to_string(), + chain: vec![lifted, chain.chain[1].clone()], + terminates_at_steward_bootstrap: true, + }; + + // Everything the old verifier checked is still intact … + let canonical = crate::jcs::canonicalize(&attack.chain[0].registration_envelope).unwrap(); + assert_eq!( + hex::encode(Sha256::digest(&canonical)), + attack.chain[0].original_content_hash, + "content hash still matches — the envelope is genuine" + ); + let sig = base64::engine::general_purpose::STANDARD + .decode(&attack.chain[0].scrub_signature_classical) + .unwrap(); + assert!( + Ed25519Verifier::new() + .verify(&steward.ed_pub(), &canonical, &sig) + .is_ok(), + "signature still verifies — it really was signed by the real parent" + ); + + // … and the chain is refused anyway, on the subject. + match verify_provenance_chain(&attack, std::slice::from_ref(&anchor)) { + Err(ProvenanceError::SubjectBindingFailed { key_id, source }) => { + assert_eq!(key_id, "mallory"); + assert!(matches!( + source, + crate::subject_binding::SubjectBindingError::Mismatch { .. } + )); + }, + other => panic!("lifted envelope MUST be refused on the subject, got {other:?}"), + } + } + + /// Swapping only the PQC leg must be refused too — binding the name alone + /// (or only the classical key) loses to an attacker substituting a PQC key + /// the signature never covered (#252 rule 1). + #[test] + fn substituting_only_the_pqc_key_is_refused() { let (mut chain, anchor, ..) = valid_chain(); - chain.chain[1].identity_type = "agent".to_string(); + chain.chain[0].pubkey_ml_dsa_65_base64 = Some(Keypair::new().mldsa_pub_b64()); + assert!(matches!( + verify_provenance_chain(&chain, std::slice::from_ref(&anchor)), + Err(ProvenanceError::SubjectBindingFailed { .. }) + )); + } + + /// An envelope that simply omits the binding is REFUSED, never tolerated — + /// an optional check is skippable by omission, which is the whole attack + /// (#252 rule 3). + #[test] + fn envelope_without_the_binding_is_refused_not_tolerated() { + let (mut chain, anchor, _child, steward) = valid_chain(); + let stripped = serde_json::json!({ "salt": 1 }); + let canonical = crate::jcs::canonicalize(&stripped).unwrap(); + // Re-sign it properly, so ONLY the binding is missing. + let sig = steward.ed.sign(&canonical).unwrap(); + let mut bound = canonical.clone(); + bound.extend_from_slice(&sig); + let b64 = base64::engine::general_purpose::STANDARD; + chain.chain[0].registration_envelope = stripped; + chain.chain[0].original_content_hash = hex::encode(Sha256::digest(&canonical)); + chain.chain[0].scrub_signature_classical = b64.encode(&sig); + chain.chain[0].scrub_signature_pqc = Some(b64.encode(steward.mldsa.sign(&bound).unwrap())); + + assert!(matches!( + verify_provenance_chain(&chain, std::slice::from_ref(&anchor)), + Err(ProvenanceError::SubjectBindingFailed { + source: crate::subject_binding::SubjectBindingError::Missing { .. }, + .. + }) + )); + } + + #[test] + fn terminus_not_steward_is_rejected() { + // Coherent object: the envelope agrees that this terminus is an + // `agent`. Post-hoc mutation would only re-test the #252 binding. + let (chain, anchor) = chain_with_terminus_type("agent"); assert!(matches!( verify_provenance_chain(&chain, &[anchor]), Err(ProvenanceError::TerminusNotSteward { .. }) @@ -966,15 +1159,15 @@ mod tests { /// the pre-RC5 scalar `!= "steward"` check would have wrongly rejected it. #[test] fn multi_role_fabric_node_steward_terminus_verifies() { - let (mut chain, anchor, ..) = valid_chain(); - chain.chain[1].identity_type = "steward,witness".to_string(); + let (chain, anchor) = chain_with_terminus_type("steward,witness"); assert!(verify_provenance_chain(&chain, std::slice::from_ref(&anchor)).is_ok()); // …and a set that does NOT contain "steward" is still rejected - // (membership, not substring — "stewardship" must not match). - chain.chain[1].identity_type = "witness,stewardship".to_string(); + // (membership, not substring — "stewardship" must not match). Built + // coherently so this exercises the terminus rule, not the binding. + let (bad, bad_anchor) = chain_with_terminus_type("witness,stewardship"); assert!(matches!( - verify_provenance_chain(&chain, &[anchor]), + verify_provenance_chain(&bad, &[bad_anchor]), Err(ProvenanceError::TerminusNotSteward { .. }) )); } diff --git a/src/ciris-verify-core/src/subject_binding.rs b/src/ciris-verify-core/src/subject_binding.rs new file mode 100644 index 0000000..b05ddd2 --- /dev/null +++ b/src/ciris-verify-core/src/subject_binding.rs @@ -0,0 +1,291 @@ +//! **"Who is this ABOUT?"** — the question a signature check does not answer +//! (CIRISVerify#252, CIRISPersist#660). +//! +//! ## The class +//! +//! Every authority gate answers *"who signed?"*. A quorum verifies m-of-n over +//! `JCS(envelope)` and **nothing else** — so if the value naming the subject is +//! not compared against those bytes, the same valid signature applies to any +//! subject. +//! +//! Concretely, the shape that shipped here: a `ProvenanceLink` declares a +//! `key_id` and pubkeys *outside* the signed envelope, and the verifier +//! canonicalized the envelope, hashed it, and verified signatures over it — +//! without ever reading the `key_id` **inside**. An attacker wraps a victim's +//! genuine, validly-signed envelope in a link declaring their own identity. +//! Content hash matches (it really is the victim's envelope), signatures verify +//! (really signed by the real parent), linkage passes — and the chain roots the +//! *attacker's* key. +//! +//! The binding was already in the signed bytes. Nobody opened the letter. +//! +//! ## The four rules, each paid for +//! +//! 1. **Bind the identity, not just the name.** Binding `key_id` alone loses to +//! a race: on a node that has not replicated the victim's row, an attacker +//! registers the victim's `key_id` with their **own** pubkeys. Bind every +//! key leg, with absence asserted as JSON `null`, so substituting a PQC key +//! the signature never covered is refused exactly as a differing string is. +//! 2. **The checker ITERATES the projection.** Adding a member extends the +//! check with no second edit — exhaustive by construction rather than by +//! anyone remembering. This is what makes the fix safe to reuse unchanged. +//! 3. **Fail CLOSED on absence.** An envelope not carrying a projected member +//! is REFUSED, never tolerated: *an optional check is skippable by omission, +//! which is the whole attack.* +//! 4. **Check the binding FIRST**, before roster, anchor or custody +//! resolution, so the refusal is deterministic regardless of that state. +//! +//! One trap worth inheriting from persist: *"this record confers nothing +//! today"* is **not** a reason to skip binding it. Records become conferral +//! subjects through elevation paths added later. + +use serde_json::{Map, Value}; + +/// A subject binding did not hold. Every variant is a refusal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubjectBindingError { + /// The signed object is not a JSON object, so it carries no binding at all. + NotAnObject { + /// What was being checked, for the diagnostic. + context: &'static str, + }, + /// A projected member is **absent** from the signed bytes — refused, never + /// tolerated (rule 3). + Missing { + /// What was being checked. + context: &'static str, + /// The member the projection requires. + member: String, + }, + /// A projected member is present but names a **different subject** — the + /// attack this module exists to refuse. + Mismatch { + /// What was being checked. + context: &'static str, + /// The member that disagreed. + member: String, + /// What the signed bytes actually say. + signed: String, + /// What the carrier claimed. + claimed: String, + }, +} + +impl std::fmt::Display for SubjectBindingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotAnObject { context } => { + write!(f, "{context}: signed payload is not a JSON object, so it carries no subject binding") + }, + Self::Missing { context, member } => write!( + f, + "{context}: signed bytes do not carry `{member}` — refusing, \ + an absent binding is skippable by omission" + ), + Self::Mismatch { + context, + member, + signed, + claimed, + } => write!( + f, + "{context}: subject mismatch on `{member}` — signed bytes say {signed}, \ + carrier claims {claimed}. The signature is valid but it is about a \ + DIFFERENT subject." + ), + } + } +} + +impl std::error::Error for SubjectBindingError {} + +/// Builder for the set of members that name **who a signed object is about**. +/// +/// Callers construct one per verified object and hand it to +/// [`check`](SubjectBinding::check), which iterates it (rule 2). +#[derive(Debug, Clone, Default)] +pub struct SubjectBinding { + members: Map, +} + +impl SubjectBinding { + /// Start an empty projection. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Require `member` in the signed bytes to equal `value`. + #[must_use] + pub fn require(mut self, member: &str, value: impl Into) -> Self { + self.members.insert(member.to_string(), value.into()); + self + } + + /// Require an **optional** key leg, asserting absence as JSON `null` + /// (rule 1) — so a PQC key the signature never covered cannot be + /// substituted in, and is refused exactly as a differing string is. + #[must_use] + pub fn require_optional(mut self, member: &str, value: Option<&str>) -> Self { + self.members.insert( + member.to_string(), + value.map_or(Value::Null, |v| Value::String(v.to_string())), + ); + self + } + + /// The projected members, for tests and diagnostics. + #[must_use] + pub fn members(&self) -> &Map { + &self.members + } + + /// **Check the projection against the signed bytes**, iterating every + /// projected member. + /// + /// `signed` MUST be the object whose canonicalization the signature + /// actually covers — passing anything else re-opens the hole this closes. + /// + /// # Errors + /// [`SubjectBindingError`] on a non-object payload, an absent member, or a + /// member naming a different subject. + pub fn check(&self, context: &'static str, signed: &Value) -> Result<(), SubjectBindingError> { + let obj = signed + .as_object() + .ok_or(SubjectBindingError::NotAnObject { context })?; + + for (member, expected) in &self.members { + // Absent is REFUSED, not tolerated — except that an expected + // `null` is satisfied by an absent member, since JCS producers + // legitimately omit rather than materialize a null (CEG §0.9 + // omit-vs-materialize). A *present* value still must match. + let actual = match obj.get(member) { + Some(v) => v, + None if expected.is_null() => continue, + None => { + return Err(SubjectBindingError::Missing { + context, + member: member.clone(), + }) + }, + }; + if actual != expected { + return Err(SubjectBindingError::Mismatch { + context, + member: member.clone(), + signed: actual.to_string(), + claimed: expected.to_string(), + }); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn matching_subject_passes() { + let signed = json!({"key_id": "k1", "pubkey_ed25519_base64": "AAA", "extra": 1}); + SubjectBinding::new() + .require("key_id", "k1") + .require("pubkey_ed25519_base64", "AAA") + .check("t", &signed) + .expect("a coherent object must pass"); + } + + /// The attack: valid signature, different subject. + #[test] + fn differing_subject_is_a_mismatch() { + let signed = json!({"key_id": "victim"}); + let err = SubjectBinding::new() + .require("key_id", "mallory") + .check("t", &signed) + .unwrap_err(); + assert!(matches!(err, SubjectBindingError::Mismatch { .. })); + assert!(err.to_string().contains("DIFFERENT subject")); + } + + /// Rule 3: absence is REFUSED, not tolerated — an optional check is + /// skippable by omission, which is the whole attack. + #[test] + fn absent_member_is_refused_not_tolerated() { + let signed = json!({"something_else": 1}); + assert!(matches!( + SubjectBinding::new() + .require("key_id", "k1") + .check("t", &signed), + Err(SubjectBindingError::Missing { .. }) + )); + } + + /// Rule 1: an optional leg the carrier claims must be present in the + /// signed bytes — substituting a key the signature never covered is + /// refused exactly as a differing string is. + #[test] + fn substituted_optional_leg_is_refused() { + let signed = json!({"pqc": "REAL"}); + assert!(matches!( + SubjectBinding::new() + .require_optional("pqc", Some("SUBSTITUTED")) + .check("t", &signed), + Err(SubjectBindingError::Mismatch { .. }) + )); + } + + /// A carrier claiming *no* optional leg while the signed bytes declare one + /// is also refused — the asymmetric direction, which is how a downgrade + /// would sneak in. + #[test] + fn dropping_a_declared_optional_leg_is_refused() { + let signed = json!({"pqc": "REAL"}); + assert!(matches!( + SubjectBinding::new() + .require_optional("pqc", None) + .check("t", &signed), + Err(SubjectBindingError::Mismatch { .. }) + )); + } + + /// An expected-null leg is satisfied by omission, since JCS producers + /// legitimately omit rather than materialize a null (CEG §0.9). This is + /// the ONE tolerated absence, and only when the carrier claims nothing. + #[test] + fn expected_null_is_satisfied_by_omission() { + let signed = json!({"key_id": "k1"}); + SubjectBinding::new() + .require("key_id", "k1") + .require_optional("pqc", None) + .check("t", &signed) + .expect("omit-vs-materialize: absent == null when nothing is claimed"); + } + + /// Rule 2: the checker ITERATES, so a projection member added later is + /// enforced with no second edit. + #[test] + fn every_projected_member_is_checked() { + let signed = json!({"a": "1", "b": "2", "c": "WRONG"}); + let sb = SubjectBinding::new() + .require("a", "1") + .require("b", "2") + .require("c", "3"); + assert_eq!(sb.members().len(), 3); + assert!(matches!( + sb.check("t", &signed), + Err(SubjectBindingError::Mismatch { member, .. }) if member == "c" + )); + } + + #[test] + fn non_object_payload_carries_no_binding() { + assert!(matches!( + SubjectBinding::new() + .require("k", "v") + .check("t", &json!("a string")), + Err(SubjectBindingError::NotAnObject { .. }) + )); + } +} diff --git a/src/ciris-verify-core/src/transport_binding.rs b/src/ciris-verify-core/src/transport_binding.rs index 8fd4601..a8dc4f1 100644 --- a/src/ciris-verify-core/src/transport_binding.rs +++ b/src/ciris-verify-core/src/transport_binding.rs @@ -211,6 +211,12 @@ pub enum TransportBindingReason { /// recomputes per §5.6.8.8.1.1 (carries /// [`DestinationHashCheck::Match`]). Verified, + /// **The signed envelope is about a different subject than the carrier + /// claims** (CIRISVerify#252) — a lifted occurrence envelope re-presented + /// with a substituted `attesting_key_id`, transport destination, or + /// encryption pubkeys. The signature may be entirely valid; it is simply + /// not about this binding. + SubjectMismatch, /// The claimed `attesting_key_id` is not present in the caller's /// `key_directory`, or its pinned pubkeys are malformed. UnknownSigner, @@ -327,6 +333,53 @@ pub fn verify_transport_binding( binding: &TransportBinding, key_directory: &[ThresholdMember], ) -> Result { + // ---- (0) SUBJECT BINDING: "who is this ABOUT?" (#252) -------------- + // Checked FIRST, before key lookup, key separation, and the dest-hash + // recompute, so the refusal is deterministic (rule 4). + // + // `attesting_key_id`, `transport_destination` and `encryption_pubkeys` + // are struct fields living OUTSIDE the signed bytes, while the producer + // (`self_at_login::sign_transport_binding`) signs all three INSIDE the + // occurrence envelope. Nothing compared them, so an attacker could keep a + // victim's `attesting_key_id` — making the signature verify — while + // substituting their OWN transport destination. Every downstream check + // then ran against the substituted address, and the verdict reported the + // victim's identity as bound to the attacker's address: a traffic-redirect + // primitive built out of a perfectly valid signature. Substituting + // `encryption_pubkeys` is the same trick aimed at content encryption. + { + use crate::subject_binding::SubjectBinding; + let td = &binding.transport_destination; + let mut sb = SubjectBinding::new() + .require("attesting_key_id", binding.attesting_key_id.clone()) + .require( + "transport_destination", + serde_json::json!({ + "reticulum_x25519_pubkey": td.reticulum_x25519_pubkey_base64, + "reticulum_ed25519_pubkey": td.reticulum_ed25519_pubkey_base64, + "destination_hash": td.destination_hash_base64, + "app_name": td.app_name, + "aspects": td.aspects, + }), + ); + // Materialize-when-present on the producer side, so absence here must + // mean absence there — asserted as JSON null (rule 1), never skipped. + sb = match &binding.encryption_pubkeys { + Some(enc) => sb.require( + "encryption_pubkeys", + serde_json::json!({ + "x25519_base64": enc.x25519_base64, + "ml_kem_768_base64": enc.ml_kem_768_base64, + }), + ), + None => sb.require_optional("encryption_pubkeys", None), + }; + if let Err(e) = sb.check("transport binding", &binding.signed_envelope) { + tracing::warn!(error = %e, "transport binding refused on subject binding"); + return Ok(reject(TransportBindingReason::SubjectMismatch)); + } + } + // ---- (2a) structural decode of every byte field, fail-closed ------- let Some(transport_ed) = binding.transport_destination.ed25519_pubkey() else { return Ok(reject(TransportBindingReason::Malformed)); @@ -614,6 +667,24 @@ mod tests { /// Build the signed occurrence envelope + the typed transport_dest, /// hybrid-sign it, and assemble a [`TransportBinding`]. `transport_ed` /// lets a test force the AV-17 collision (transport ed == signing ed). + /// Set a `transport_destination` member in BOTH the struct and the signed + /// envelope, then re-sign. + /// + /// Since #252 the two must agree, which is the point: mutating only the + /// struct is now the *lifted-envelope attack*, not a malformed field. A + /// test that wants to exercise structural decoding has to produce a + /// coherent object, or it is only re-testing the subject binding. + fn set_td_member( + binding: &mut TransportBinding, + member: &str, + value: serde_json::Value, + signer: &Signer, + ) { + binding.signed_envelope["transport_destination"][member] = value; + let bytes = jcs::canonicalize(&binding.signed_envelope).unwrap(); + binding.signature = signer.sign(&bytes); + } + fn make_binding( signer: &Signer, key_id: &str, @@ -902,17 +973,116 @@ mod tests { None, ); - // Truncated transport ed pubkey (16 bytes, not 32) → Malformed, - // caught before any signature work. + // Truncated transport ed pubkey (16 bytes, not 32) → Malformed. + // Applied coherently so this tests structural decoding, not the + // #252 subject binding. + let bad = b64().encode(vec![0u8; 16]); binding .transport_destination - .reticulum_ed25519_pubkey_base64 = b64().encode(vec![0u8; 16]); + .reticulum_ed25519_pubkey_base64 = bad.clone(); + set_td_member( + &mut binding, + "reticulum_ed25519_pubkey", + bad.into(), + &signer, + ); let v = verify_transport_binding(&binding, &dir).unwrap(); assert!(!v.authentic); assert_eq!(v.reason, TransportBindingReason::Malformed); } + /// **CIRISVerify#252 on the transport surface — the redirect, refused.** + /// + /// Mallory takes a victim's genuine, validly-signed occurrence envelope + /// and re-presents it with HER OWN transport destination. She keeps + /// `attesting_key_id` as the victim's, so the signature still verifies + /// against the victim's pinned key — the whole point. + /// + /// Pre-#252 every downstream check (key separation, destination-hash + /// recompute) ran against HER substituted destination and agreed, because + /// they read the struct field. The verdict then reported the victim's + /// identity as reachable at Mallory's address: a traffic-redirect built + /// out of a perfectly valid signature. + #[test] + fn substituted_transport_destination_is_refused() { + let signer = Signer::random(); + let dir = vec![signer.directory_member("steward-us")]; + let mut binding = make_binding( + &signer, + "steward-us", + &pubkey_bytes(0x01), + &pubkey_bytes(0x02), + None, + ); + // Sanity: it verifies before tampering. + assert!(verify_transport_binding(&binding, &dir).unwrap().authentic); + + // Mallory's address, self-consistent so the hash recompute agrees. + let (m_ed, m_x) = (pubkey_bytes(0xAA), pubkey_bytes(0xBB)); + let app_name = "ciris.federation"; + let aspects = ["announce", "v1"]; + let m_hash = rns_destination_hash(&m_x, &m_ed, app_name, &aspects); + binding.transport_destination = TransportDestination { + reticulum_x25519_pubkey_base64: b64().encode(&m_x), + reticulum_ed25519_pubkey_base64: b64().encode(&m_ed), + destination_hash_base64: b64().encode(&m_hash), + app_name: app_name.to_string(), + aspects: aspects.iter().map(|s| (*s).to_string()).collect(), + }; + // Envelope and signature untouched — still the victim's, still valid. + + let v = verify_transport_binding(&binding, &dir).unwrap(); + assert!(!v.authentic, "a substituted destination MUST NOT verify"); + assert_eq!(v.reason, TransportBindingReason::SubjectMismatch); + } + + /// The same trick aimed at content encryption: swap `encryption_pubkeys` + /// so material is encrypted to Mallory's KEM key. + #[test] + fn substituted_encryption_pubkeys_are_refused() { + let signer = Signer::random(); + let dir = vec![signer.directory_member("steward-us")]; + let mut binding = make_binding( + &signer, + "steward-us", + &pubkey_bytes(0x01), + &pubkey_bytes(0x02), + Some(&pubkey_bytes(0x03)), + ); + assert!(verify_transport_binding(&binding, &dir).unwrap().authentic); + + binding.encryption_pubkeys = Some(EncryptionPubkeys { + x25519_base64: b64().encode(pubkey_bytes(0xCC)), + ml_kem_768_base64: b64().encode(vec![0x22u8; 1184]), + }); + + let v = verify_transport_binding(&binding, &dir).unwrap(); + assert!(!v.authentic); + assert_eq!(v.reason, TransportBindingReason::SubjectMismatch); + } + + /// Dropping `encryption_pubkeys` from the carrier while the signed + /// envelope still declares them must also be refused — absence is + /// asserted, not skipped (#252 rule 1/3). + #[test] + fn dropping_declared_encryption_pubkeys_is_refused() { + let signer = Signer::random(); + let dir = vec![signer.directory_member("steward-us")]; + let mut binding = make_binding( + &signer, + "steward-us", + &pubkey_bytes(0x01), + &pubkey_bytes(0x02), + Some(&pubkey_bytes(0x03)), + ); + binding.encryption_pubkeys = None; + + let v = verify_transport_binding(&binding, &dir).unwrap(); + assert!(!v.authentic); + assert_eq!(v.reason, TransportBindingReason::SubjectMismatch); + } + #[test] fn malformed_destination_hash_rejected() { let signer = Signer::random(); @@ -925,8 +1095,12 @@ mod tests { None, ); - // destination_hash wrong length (32, not 16) → Malformed. - binding.transport_destination.destination_hash_base64 = b64().encode(vec![0u8; 32]); + // destination_hash wrong length (32, not 16) → Malformed. Applied + // coherently (struct + envelope + re-sign) so this exercises the + // structural check rather than the #252 binding. + let bad = b64().encode(vec![0u8; 32]); + binding.transport_destination.destination_hash_base64 = bad.clone(); + set_td_member(&mut binding, "destination_hash", bad.into(), &signer); let v = verify_transport_binding(&binding, &dir).unwrap(); assert!(!v.authentic); @@ -947,6 +1121,12 @@ mod tests { binding.transport_destination.reticulum_x25519_pubkey_base64 = "!!! not base64 !!!".to_string(); + set_td_member( + &mut binding, + "reticulum_x25519_pubkey", + "!!! not base64 !!!".into(), + &signer, + ); let v = verify_transport_binding(&binding, &dir).unwrap(); assert!(!v.authentic);