Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|--------|-------|
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/ciris_verify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def get_library_version() -> str:
return __version__


__version__ = "13.0.0"
__version__ = "13.1.0"
__all__ = [
"CIRISVerify",
"MockCIRISVerify",
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions evidence/cc_impl.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 30 additions & 0 deletions src/ciris-verify-core/src/federation_self_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/ciris-verify-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading