From 01852d7d013f155e6eb89cb2d2cc967f727bb59e Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 13 Aug 2026 09:26:39 -0500 Subject: [PATCH] =?UTF-8?q?fix(13.1.0):=20bind=20the=20SUBJECT,=20not=20ju?= =?UTF-8?q?st=20the=20signer=20=E2=80=94=20three=20surfaces=20(#252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY. 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. Swept for the shape -- a struct carrying an opaque signed Value alongside decision-driving sibling fields -- and 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). 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 run against her address and agree, and the verdict reports the victim's identity as reachable at Mallory's address. Same trick on encryption_pubkeys aims 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. New subject_binding implements persist's four rules: bind the identity not just the name (both key legs -- 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, 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. Verifier-only, no wire change: 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. Five fixtures had to be rebuilt, which is the finding under the finding: each mutated a field OUTSIDE the signed envelope to simulate tampering -- now precisely the refused attack -- so they asserted 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. 954 core green, 1348 workspace, clippy clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FZPhbXWTzykcbotEX9BN2p --- CLAUDE.md | 3 +- Cargo.lock | 14 +- Cargo.toml | 2 +- bindings/python/ciris_verify/__init__.py | 2 +- bindings/python/pyproject.toml | 2 +- evidence/cc_impl.tsv | 2 + .../src/federation_self_record.rs | 30 ++ src/ciris-verify-core/src/lib.rs | 1 + .../src/manifest_contribution.rs | 69 ++++- src/ciris-verify-core/src/provenance.rs | 213 ++++++++++++- src/ciris-verify-core/src/subject_binding.rs | 291 ++++++++++++++++++ .../src/transport_binding.rs | 190 +++++++++++- 12 files changed, 792 insertions(+), 27 deletions(-) create mode 100644 src/ciris-verify-core/src/subject_binding.rs 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);