From d5ef3b5331815f80fdf1357fc2700c19048a7752 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Sun, 6 Sep 2026 10:56:04 -0500 Subject: [PATCH] feat(15.0.0)!: FedCode is non_exhaustive + admission is unconstructible unless verified (#274); CC decimals assigned (#275) CIRISEdge reviewed v14.2.0 during its v20.2.0 adopt, found the design sound, and raised two structural items. Both are right. 1. FedCode grew a public field in a MINOR -- twice. v14.1.0 added owned_nodes; v14.2.0 added ml_dsa_65_pubkey_sha256. Both to a public non-#[non_exhaustive] struct, which is a compile break for every struct-literal constructor: 12 call sites in edge, and the version number said it was safe to take blind. That is exactly the hazard #257 raised and v14.0.0 was cut to fix. But that sweep annotated 31 error ENUMS and ZERO structs -- so the class survived in the half it never looked at, and I shipped it twice through that half. FedCode and OwnedNode are now #[non_exhaustive] with FedCode::new + with_* builders and OwnedNode::new, so a future field is an added method. Scope decided by evidence, not taste: 236 public structs here carry public fields, and blanket-annotating would break destructuring for readers to buy nothing. A downstream sweep asked which are actually CONSTRUCTED by consumers -- FedCode (12) and OwnedNode (5) are the only two. VerifyConfig, FullAttestationRequest, RegisterArgs, AttestationProof: all zero. Result types are read, not built. 2. The commitment check had no enforcing call site. verify_pulled_ml_dsa_65_pubkey is the right primitive in the wrong shape: a free function returning Result<(), _>, so nothing structurally stopped a host from pulling an ML-DSA body and registering it unchecked -- and that failure is SILENT, a registered hybrid whose PQC half came from whoever answered the Pull. New AdmittedHybridKey fixes the class rather than trusting each call site: private fields, one fallible constructor taking the code and the pulled bytes together, so a value exists only on match. A host taking it as its registration input cannot express the unchecked path. Same discipline as Validity::checked. Edge's framing is adopted into FSD-003 because a reader will ask: the commitment proves the ML-DSA half is the one the minter committed to, NOT that one party holds both halves. Nobody cross-signs them at registration and need not -- persist admits hybrid only and every row must verify under both signatures, so joint control is proven at FIRST USE, not registration. Also: bump-version.sh now rewrites intra-workspace sibling pins, which a major bump had left stale by hand at 13.0.0, 14.0.0 and 15.0.0 before automating it. #275: all 7 UNASSIGNED evidence rows carry CC decimals (CIRISConstitution#101); 53 citations resolve, none unassigned. 1491 workspace green, clippy clean, doc clean, guards pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U4djyL5Vx6whagKB7J4586 --- CLAUDE.md | 3 +- Cargo.lock | 14 +- Cargo.toml | 2 +- FSD/FSD-003_FEDERATION_IDENTITY_CODES.md | 23 +- README.md | 2 +- bindings/python/ciris_verify/__init__.py | 2 +- bindings/python/pyproject.toml | 2 +- evidence/cc_impl.tsv | 14 +- scripts/bump-version.sh | 26 ++ src/ciris-keyring/Cargo.toml | 2 +- src/ciris-verify-core/src/bin/ciris_verify.rs | 20 +- src/ciris-verify-core/src/fedcode.rs | 261 ++++++++++++++++++ 12 files changed, 339 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b04c0620..54645325 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: **14.2.0** — see [Release history](#release-history) for this and every prior entry.)* +*(Current release: **15.0.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`). +- **15.0.0** (BREAKING) — **the #257 semver sweep covered 33 enums and 0 structs, and the half nobody looked at shipped the same break twice (#274)**. CIRISEdge reviewed v14.2.0 during its v20.2.0 adopt, found the design sound, and raised two structural items. **(1) `FedCode` grew a public field in a MINOR — twice.** v14.1.0 added `owned_nodes`, v14.2.0 added `ml_dsa_65_pubkey_sha256`, both to a public non-`#[non_exhaustive]` struct: a compile break for every struct-literal constructor, **12 call sites in edge**, and *the version number said it was safe to take blind*. That is precisely the hazard #257 raised and v14.0.0 was cut to fix — but that sweep annotated **31 error enums and zero structs**, so the class survived in the half it never looked at. `FedCode` and `OwnedNode` are now `#[non_exhaustive]` with `FedCode::new` + `with_*` builders and `OwnedNode::new`, so a future field is an added method rather than a break. **Scope was decided by evidence, not taste:** 236 public structs in this repo carry public fields, and a blanket annotation would break destructuring for readers to buy nothing — so a downstream sweep asked which are actually *constructed* by consumers. `FedCode` (12) and `OwnedNode` (5) are **the only two**; `VerifyConfig`, `FullAttestationRequest`, `RegisterArgs` and `AttestationProof` are all zero. Result types are read, not built. **(2) The commitment check had no enforcing call site.** `verify_pulled_ml_dsa_65_pubkey` is the right primitive in the wrong shape — a free function returning `Result<(), _>`, so nothing structurally stopped a host from pulling an ML-DSA body and registering it unchecked, and that failure is **silent**: a registered hybrid whose PQC half came from whoever answered the Pull. New **`AdmittedHybridKey`** fixes the class instead of trusting each call site: private fields, one fallible constructor (`admit`) taking the code and the pulled bytes together, so a value exists **only** on match and a host taking it as its registration input *cannot express* the unchecked path — the `Validity::checked` discipline applied to admission. **Edge's framing adopted into the FSD**, because a reader will ask: the commitment proves the ML-DSA half is the one the minter committed to, **not** that one party holds both halves — nobody cross-signs them at registration and need not, since persist admits `hybrid` only and every row must verify under **both** signatures. Joint control is proven at **first use**, not registration. Also: `bump-version.sh` now rewrites intra-workspace sibling pins, which a major bump had left stale by hand at 13.0.0, 14.0.0 **and** 15.0.0 before being automated. #275: all 7 `UNASSIGNED` evidence rows carry CC decimals (CIRISConstitution#101) — 53 citations resolve, none unassigned. 1491 workspace green, clippy clean. - **14.2.0** — **the v3 code named a key that could never be admitted (#272)**. A fedcode carries an Ed25519 identity and nothing else, but CIRISPersist accepts `federation_keys` writes only at `algorithm: "hybrid"` — so a code-admitted key had **no PQC half to register**, `ContactResolution::ReadyFromCode` was unreachable, and first contact had no working path at all. Reported end-to-end from a live 3-node mesh, where an owner-binding written at `cohort_scope: self` is correctly withheld from every peer, `nodes_owned_by(peer)` is therefore permanently empty on the far side, and **every `chat:` row is withheld in both directions** — including the MLS KeyPackage that would key the room. Two nodes that had peered, consented and derived the same community id could not exchange one message, **with nothing in the path malfunctioning**. v14.1.0's v3 code was the designed way out and could not be taken. **The key is 1952 bytes** — inlining it takes a code past 3 KB and ends its life as something a person can type or read aloud — so the code carries a **32-byte commitment**, `sha256(ml_dsa_65_pubkey)`: the host admits the classical half plus the digest, pulls the ML-DSA body, and verifies it against the commitment **before** writing. Persist's hybrid rule is **preserved, not weakened** — nothing registers until both halves are present and bound. Same pattern as #113/#116, which committed this exact key for the same size reason. **One correction to the proposal as filed:** it scoped the commitment to the v3 node tail, but the admission gap affects **every** code, and a node-free `user` code is the shape most likely to be handed over at first contact — so either trigger emits v3, and a commitment-only code is valid with `node_count = 0`. Presence-tagged and **last**, so a v3 code minted by v14.1.0 still decodes with the commitment absent (asserted by a test that hand-builds the pre-#272 payload). `verify_pulled_ml_dsa_65_pubkey` is the one blessed check and **fails closed on a code with no commitment** — there is nothing to bind the pull to. Constant-time, since the match gates admission. Trust level stated in the FSD rather than left to be assumed: a fedcode is **unsigned**, so the commitment inherits exactly the code's trust and adds no authority — what it buys is that a Key Pull cannot be substituted after the fact. **The producer emits it, so the support is not inert:** `create_federation_identity` reads the ML-DSA pubkey back off the record it just built (same bytes as the registered key, not an independent derivation) and commits to it — caught before merge, because emitting `None` there would have left the whole feature unused for exactly the population that matters, the same dead-branch class as v14.1.0's `VerifiedJson`. **Behavior change:** a minted identity code is therefore **v3, not v2**. A consumer older than v14.1.0 rejects `CIRIS-V3-` outright — the safe direction, since it could not have admitted the key either. FSD-003 §3A.5 normative. 11 tests; 1487 workspace green, clippy clean. - **14.1.0** — **four backlog fixes: a false alarm that also disabled the real alarm (#224), a user code that can carry its own nodes (#269), the keyring's own key mints bypassing the RNG latch (#207 item 6), and a two-month misdiagnosis in a log line (#176)**. **(3) #207 item 6 — the #74 invariant did not hold where it matters most.** v5.6.0 made keygen fail-secure so *"no weak key is ever produced"* — but `ciris-keyring`'s own mints drew **raw `OsRng`**, bypassing the SP 800-90B health latch entirely. So the invariant held for `ciris-crypto`-constructed keys and **not** for keyring-sealed ones, which are the **federation identity keys**. Every key-material draw now routes through `ciris_crypto::random::fill` — the sealed Ed25519 seed, the sealed ML-DSA-65 seed, the USB-wrapped PQC seed, the transport identity, the software wrapper key, and three P-256 mints via a new `mint_p256_signing_key` (which puts the **bytes** through the latch rather than merely probing it and then drawing unchecked). This required making `ciris-crypto` a **non-optional** keyring dependency at the `random` feature only: gating the latch behind `pqc-ml-dsa` meant the *default* keyring build had no latch at all. Incidental draws are deliberately left alone — a temp-dir suffix and a TPM attestation nonce are not key material. Proven by a fail-secure test, the one #74 gave every `ciris-crypto` primitive and the keyring never had. **(4) #176 — the title says build record; the failing decode is the steward key.** `validation::query_https_source` → `get_steward_key` → `StewardKeyResponse`, which requires `classical`. Two months of pointing at build records. The parse itself **cannot** be fixed here — the registry sends the multi-steward shape, verify's modern parser for it *requires* a `response_signature` the registry does not send, and relaxing that would mean silently accepting **unsigned trust-root material** (escalated as CIRISRegistry#133) — so the honest fix is to stop misdirecting the reader: the error now names the endpoint, the drift, the tracking issue, and *"NOT a build-record problem and NOT a network problem"*. An unrelated parse error is passed through untouched, asserted by test. v13.3.0 had already fixed the *"HTTPS unreachable"* mislabel on this path; this fixes the remaining misdirection. **(1) #224 — `verify_manifest_integrity` reported a benign algorithm mismatch as tampering, and skipped the actual check.** **(1) #224 — `verify_manifest_integrity` reported a benign algorithm mismatch as tampering, and skipped the actual check.** This repository contains **three** `manifest_hash` constructions — concatenated per-file hashes (`file_integrity`), concatenated per-*function* hashes (`ciris-manifest-tool`), and SHA-256 over serde-JSON bytes (`ciris-build-tool`) — so a manifest produced by one and checked by another can never match. The old code recomputed exactly one, **self-diagnosed the benign cause in its own warning**, and still reported a tampering-shaped ERROR: 14 of them in a 4h window on `datum`, one per Discord reconnect. **The half nobody reported is worse:** `check_full` then returned early with `files_checked: 0`, so the per-file hashes — the control that can actually detect tampering — were never checked. That is the same symptom #176 reports as *"L4 file integrity will be skipped"*. Now it **tries the other construction** instead of guessing, and returns `ManifestHashCheck::{VerifiedConcatenated, VerifiedJson, Unrecognized}`. `Unrecognized` is deliberately not called *mismatch*: with only `files` + `manifest_hash` in hand a fourth construction is **indistinguishable from tampering**, so asserting either manufactures a verdict from a measurement we cannot make (`MISSION.md` §1.4 — the distinction v13.3.0 drew for revocation). **A contract change consumers must know about:** an unrecognized hash no longer flips `integrity_valid`; it is surfaced on its own field and the per-file check *runs*. Altering `manifest_hash` alone achieves nothing anyway — the per-file hashes are untouched, and an attacker who altered those would simply recompute it, which this check never caught either. It is a self-consistency checksum, not an authentication. A malformed manifest (no files / empty hash) is still a hard failure. **(2) #269 — fedcode may embed the owner's nodes, so a contact resolves with no directory.** CC 5.4.6 names the population v1 cannot serve: *"phone-class peers that cannot hold the full directory"* — first contact, a QR across a table, an air-gapped hand-off. **The proposal was called "fedcode v2" and the wire format has been at v2 since the kind-tagged code shipped**, so this mints **v3** (`CIRIS-V3-`); minting it as v2 would have collided with a live encoding. Additive and free: a code with no nodes still emits **byte-identical v2**, so nothing already issued moves, and `MAY` genuinely costs nothing. **The safety property is the point.** `OwnedNode` carries the node's **transport** Ed25519 — never the owner's federation key. CIRISServer#335 is what that confusion cost: nodes primed the canonical at `1fc232535a…` while it served on `81cabcf78a…`, every node reported `knows_peer=true, provenance=Rooted, primed=1, refused=0`, and **zero traces arrived** — then the false rooting *prevented* recovery, because a node that believes it knows a peer never learns the real address. What made it survive review is that transport and federation share the Ed25519 half, so the derivation looks sound; sharing a key does not make a base hash and a named hash the same address. Refused at **both** encoder and decoder, because a code minted by another implementation is exactly the case an encoder cannot police. Only a `user` code may carry nodes (a group's destinations are group-scoped material a code must not carry at all, CC 5.4.6), and the list is capped at 16 so a code stays scannable. 7 v3 tests + 4 manifest tests; 1396 workspace green, clippy clean. - **14.0.0** (BREAKING) — **three downstream-reported defects, all of them about a promise the type system was not keeping (#257, #267, #265)**. **(1) #257 — a new error variant was a semver break shipped as a MINOR.** v13.3.0 added `VerifyError::ResponseSchemaMismatch`; the enum had no `#[non_exhaustive]`, so under Cargo semver that breaks any downstream `match` without a `_` arm — on a plain `cargo update` against `version = "13"`. CIRISPersist reported it and was careful to say *its own* code was unaffected **by luck, not design**, which is the right way to report a hazard you did not personally hit. **The fix is deliberately NOT "add it to all 110 public enums."** `#[non_exhaustive]` is right for **open** sets (errors, capability lists that grow) and **wrong** for **closed wire vocabularies** — for CoTS `Purpose`, `RecordType`, `InvocationKind`, `CohortScope` and friends, an exhaustive downstream match is a *feature*: a new variant there means the wire format moved and consumers must be forced to notice. That is the 13.0.0 `Normative`-vs-`Structural` distinction applied to packaging. So: **31 error enums annotated, 13 closed vocabularies deliberately left, `CirisVerifyError` left because it is `#[repr(C)]` and its variant set is an ABI contract.** The change immediately broke two in-workspace matches (`wheel_hybrid_kex`, `wheel_key_grant`) — the mechanism proving itself — both now carrying a `_` arm that fails closed. **(2) #267 — the only key-minting path could not express expiry**, hardcoding `valid_until: None`. The naive fix is a bare field, and it would have been **wrong in the #252 way**: `valid_until` was not in the signed envelope, so an expiry set there is **strippable** — drop it and the record still verifies. So it rides **inside** the scrub-signed envelope, materialize-when-present per CEG §0.9, which means `None` reproduces the pre-14.0 bytes **exactly** and every existing record, signature and golden vector stays valid. `valid_until_in_envelope()` is the accessor a consumer should read when the answer drives a decision; a test asserts stripping it breaks the content hash. Exposed on `produce_self_key_record` / `produce_scrubbed_key_record` / `produce_multiscrub_key_record` / `create_federation_identity`, the CLI (`--valid-until`) and the FFI. **Accord holder records deliberately pass `None`** — a self-asserted expiry on the constitutional kill-switch custody root would create a date after which the accord silently has fewer holders than its quorum needs; rotation is the m-of-n ceremony, never a timeout. **(3) #265 — the CIRIS Logging Standard.** Motivated by a real incident: a user's log ran **3,810 lines for 3m40s**, their actual fault (`HTTP 401`) appeared 13× and was unfindable, and they lost a day to it. Substrate output bypasses Python `logging` entirely, so the agent-side fixes cannot reach us — whatever this crate emits is what the operator gets, and **verify's default filter is `warn`**, so every `warn!` here is on for every user. Two §2/§1.1 violations found and fixed: a **three-line WARN banner** in `tpm_windows` (a banner is not a failure) and `conformance::log_report`, a **box-drawing ASCII table at ~15 events per report with individual table rows on the WARN channel** — now one structured INFO summary, per-test detail at DEBUG, one WARN per actual failure, one ERROR overall. §5 (*never log secrets*) already held by construction; the standard asks for it *with a test*, so **`scripts/check-no-secret-logging.sh`** parses each tracing macro's **balanced argument list** (a `grep -A` window flagged ordinary code that merely followed a log call) and fails on interpolated seed/private-key/PIN/DEK material. Negative-tested, wired into CI. 1377 workspace green, clippy clean. diff --git a/Cargo.lock b/Cargo.lock index 9536eba9..263d7502 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,7 +668,7 @@ dependencies = [ [[package]] name = "ciris-build-tool" -version = "14.2.0" +version = "15.0.0" dependencies = [ "anyhow", "base64 0.21.7", @@ -690,7 +690,7 @@ dependencies = [ [[package]] name = "ciris-crypto" -version = "14.2.0" +version = "15.0.0" dependencies = [ "chacha20poly1305", "criterion", @@ -722,7 +722,7 @@ dependencies = [ [[package]] name = "ciris-keyring" -version = "14.2.0" +version = "15.0.0" dependencies = [ "aes-gcm", "async-trait", @@ -762,7 +762,7 @@ dependencies = [ [[package]] name = "ciris-manifest-tool" -version = "14.2.0" +version = "15.0.0" dependencies = [ "anyhow", "chrono", @@ -779,7 +779,7 @@ dependencies = [ [[package]] name = "ciris-tpm-plugin" -version = "14.2.0" +version = "15.0.0" dependencies = [ "sha2", "tracing", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "ciris-verify-core" -version = "14.2.0" +version = "15.0.0" dependencies = [ "android_system_properties", "async-trait", @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "ciris-verify-ffi" -version = "14.2.0" +version = "15.0.0" dependencies = [ "aes-gcm", "android_logger", diff --git a/Cargo.toml b/Cargo.toml index 145d61d9..5219b5c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "14.2.0" +version = "15.0.0" edition = "2021" rust-version = "1.86" license = "AGPL-3.0-or-later" diff --git a/FSD/FSD-003_FEDERATION_IDENTITY_CODES.md b/FSD/FSD-003_FEDERATION_IDENTITY_CODES.md index ec622156..f375ebda 100644 --- a/FSD/FSD-003_FEDERATION_IDENTITY_CODES.md +++ b/FSD/FSD-003_FEDERATION_IDENTITY_CODES.md @@ -192,9 +192,26 @@ ml_dsa_65_pubkey_sha256 = sha256(raw ML-DSA-65 public key) implementation MUST NOT treat a commitment match as authentication of the identity. -Verify exposes `fedcode::verify_pulled_ml_dsa_65_pubkey(&code, pulled)` as the -one blessed check. A code with **no** commitment fails closed there: there is -nothing to bind the pulled key to. +Verify exposes two surfaces, and the second is the one a host should use: + +- `fedcode::verify_pulled_ml_dsa_65_pubkey(&code, pulled)` — the check itself. +- **`fedcode::AdmittedHybridKey::admit(&code, pulled)`** — the same check in an + enforcing shape. Its fields are private and this is its only constructor, so + a value exists **only** if the pull matched. A host that takes an + `AdmittedHybridKey` as its registration input cannot express the unchecked + path; with the free function alone, nothing structurally stops a host from + registering a pulled body it never checked, and that failure is silent + (CIRISVerify#274). + +A code with **no** commitment fails closed in both: there is nothing to bind +the pulled key to, so a v1/v2 code is not a hybrid registration path at all. + +**What the commitment proves, precisely.** It proves the ML-DSA half is the one +the code's minter committed to — **not** that whoever holds the Ed25519 key +also holds this ML-DSA key. Nobody cross-signs the two halves at registration, +and they need not: persist admits `algorithm: "hybrid"` only and every row must +verify under **both** signatures, so a holder of one half can never produce an +admitting row. Joint control is proven at **first use**, not at registration. ### 3A.4 Compatibility diff --git a/README.md b/README.md index 963bc155..d64a884e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Decide whether evidence about a machine, a build, or a key is worth believing — and say exactly how much.** -v14.2.0 · Rust + Python · AGPL-3.0 · Post-quantum from day one +v15.0.0 · Rust + Python · AGPL-3.0 · Post-quantum from day one CIRISVerify is an embeddable Rust library (with C FFI and a Python wheel) for **hardware attestation verification, trust-anchor management, and artifact diff --git a/bindings/python/ciris_verify/__init__.py b/bindings/python/ciris_verify/__init__.py index 6b9c3e1d..a00e0284 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__ = "14.2.0" +__version__ = "15.0.0" __all__ = [ "CIRISVerify", "MockCIRISVerify", diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 43e4e639..9c953690 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 = "14.2.0" +version = "15.0.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 52d13e91..f81435fc 100644 --- a/evidence/cc_impl.tsv +++ b/evidence/cc_impl.tsv @@ -65,11 +65,11 @@ 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 -UNASSIGNED CLM-scope-destination CIRISVerify src/ciris-crypto/src/scope_privacy.rs#derive_destination ciris-crypto@v13.4.0 +2.3.2.1 CLM-subject-binding CIRISVerify src/ciris-verify-core/src/subject_binding.rs#SubjectBinding ciris-verify-core@v13.1.0 +3.3.6 CLM-subject-binding-keyrecord CIRISVerify src/ciris-verify-core/src/federation_self_record.rs#check_subject_binding ciris-verify-core@v13.1.0 +5.4.6 CLM-scope-destination CIRISVerify src/ciris-crypto/src/scope_privacy.rs#derive_destination ciris-crypto@v13.4.0 5.4.6 CLM-announce-suppress CIRISVerify src/ciris-verify-core/src/announce_policy.rs#may_announce ciris-verify-core@v13.6.0 -UNASSIGNED CLM-key-validity-window CIRISVerify src/ciris-verify-core/src/federation_self_record.rs#valid_until_in_envelope ciris-verify-core@v14.0.0 -UNASSIGNED CLM-fedcode-owned-nodes CIRISVerify src/ciris-verify-core/src/fedcode.rs#OwnedNode ciris-verify-core@v14.1.0 -UNASSIGNED CLM-keyring-rng-latch CIRISVerify src/ciris-keyring/src/lib.rs#mint_p256_signing_key ciris-keyring@v14.1.0 -UNASSIGNED CLM-fedcode-pqc-commitment CIRISVerify src/ciris-verify-core/src/fedcode.rs#verify_pulled_ml_dsa_65_pubkey ciris-verify-core@v14.2.0 +2.1 CLM-key-validity-window CIRISVerify src/ciris-verify-core/src/federation_self_record.rs#valid_until_in_envelope ciris-verify-core@v14.0.0 +2.6.8 CLM-fedcode-owned-nodes CIRISVerify src/ciris-verify-core/src/fedcode.rs#OwnedNode ciris-verify-core@v14.1.0 +4.2.2 CLM-keyring-rng-latch CIRISVerify src/ciris-keyring/src/lib.rs#mint_p256_signing_key ciris-keyring@v14.1.0 +2.6.8 CLM-fedcode-pqc-commitment CIRISVerify src/ciris-verify-core/src/fedcode.rs#verify_pulled_ml_dsa_65_pubkey ciris-verify-core@v14.2.0 diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 239f5958..0d0b1d46 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -61,6 +61,32 @@ else echo -e "${RED}✗${NC} Cargo.toml not found" fi +# 1b. Update intra-workspace path-dependency version pins. +# +# A member crate pins a sibling by MAJOR (`version = "14"`) alongside its +# `path`. Cargo enforces that pin, so a major bump leaves the workspace +# unbuildable until it is updated — and this was missed by hand at 13.0.0, +# 14.0.0 and 15.0.0 before being automated here. Minor/patch bumps are a +# no-op, since the pin carries only the major. +NEW_MAJOR="${NEW_VERSION%%.*}" +PIN_CHANGED=0 +for member_toml in "$REPO_ROOT"/src/*/Cargo.toml; do + [ -f "$member_toml" ] || continue + # Only rewrite pins on deps that also carry a `path = "../…"` — a real + # sibling — never a same-named crates.io dependency. + if grep -qE '^ciris-[a-z-]+ = \{ path = "\.\./[^"]+", version = "[0-9]+"' "$member_toml"; then + sed -i.bak -E "s|^(ciris-[a-z-]+ = \{ path = \"\.\./[^\"]+\", version = \")[0-9]+(\")|\1${NEW_MAJOR}\2|" "$member_toml" \ + && rm -f "$member_toml.bak" + if ! git -C "$REPO_ROOT" diff --quiet -- "$member_toml" 2>/dev/null; then + PIN_CHANGED=1 + echo -e "${GREEN}✓${NC} $(basename "$(dirname "$member_toml")")/Cargo.toml: sibling pin -> \"$NEW_MAJOR\"" + fi + fi +done +if [ "$PIN_CHANGED" = "1" ]; then + CHANGES+=("intra-workspace sibling pins -> \"$NEW_MAJOR\"") +fi + # 2. Update Python pyproject.toml PYPROJECT="$REPO_ROOT/bindings/python/pyproject.toml" if [ -f "$PYPROJECT" ]; then diff --git a/src/ciris-keyring/Cargo.toml b/src/ciris-keyring/Cargo.toml index e53dc0b9..75017ee4 100644 --- a/src/ciris-keyring/Cargo.toml +++ b/src/ciris-keyring/Cargo.toml @@ -92,7 +92,7 @@ keyring = { version = "3", optional = true } # The base dependency is the `random` feature only, which is `[]` plus the # `rand_core` this crate already pulls — so the light default stays light. The # heavier PQC surface is still feature-gated below. -ciris-crypto = { path = "../ciris-crypto", version = "14", default-features = false, features = ["random"] } +ciris-crypto = { path = "../ciris-crypto", version = "15", default-features = false, features = ["random"] } # Platform-specific [target.'cfg(target_os = "android")'.dependencies] diff --git a/src/ciris-verify-core/src/bin/ciris_verify.rs b/src/ciris-verify-core/src/bin/ciris_verify.rs index 7cc7b185..eb248977 100644 --- a/src/ciris-verify-core/src/bin/ciris_verify.rs +++ b/src/ciris-verify-core/src/bin/ciris_verify.rs @@ -2537,16 +2537,18 @@ fn run_fedcode_new(a: FedcodeNew) { }; let key_id = fedcode::derive_key_id(&a.label, &ed_pub); - let fc = FedCode { - owned_nodes: Vec::new(), - ml_dsa_65_pubkey_sha256: None, + let mut fc = FedCode::new( kind, - key_id: key_id.clone(), - pubkey_ed25519_base64: base64::engine::general_purpose::STANDARD.encode(&ed_pub), - transport_hint: a.transport_hint.clone(), - alias_hint: Some(a.label.clone()), - group_key_id: a.group_key_id.clone(), - }; + key_id.clone(), + base64::engine::general_purpose::STANDARD.encode(&ed_pub), + ) + .with_alias_hint(a.label.clone()); + if let Some(h) = a.transport_hint.clone() { + fc = fc.with_transport_hint(h); + } + if let Some(g) = a.group_key_id.clone() { + fc = fc.with_group_key_id(g); + } let code = match fedcode::encode(&fc) { Ok(c) => c, Err(e) => { diff --git a/src/ciris-verify-core/src/fedcode.rs b/src/ciris-verify-core/src/fedcode.rs index e80aa957..dbeabdd1 100644 --- a/src/ciris-verify-core/src/fedcode.rs +++ b/src/ciris-verify-core/src/fedcode.rs @@ -175,6 +175,7 @@ impl FedKind { /// [`encode`] refuses a code whose embedded transport key equals the owner's /// own pubkey, so the specific mistake that caused #335 cannot be encoded. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub struct OwnedNode { /// The node's federation `key_id` — an identifier, not an address. pub key_id: String, @@ -184,7 +185,22 @@ pub struct OwnedNode { } /// A decoded / to-be-encoded fedcode. +/// +/// **`#[non_exhaustive]`, deliberately (CIRISVerify#274).** This format +/// demonstrably grows — v3 added [`owned_nodes`](Self::owned_nodes), then +/// #272 added the PQC commitment — and each addition was a public field on a +/// non-`non_exhaustive` struct shipped in a MINOR, which is a compile break +/// for every struct-literal constructor. It broke 12 call sites in CIRISEdge, +/// and the version number said it was safe to take blind. +/// +/// That is exactly the semver hazard #257 raised and v14.0.0 was cut to fix — +/// but that sweep annotated **33 enums and 0 structs**, so the same class +/// shipped twice more through the half nobody looked at. +/// +/// Construct with [`FedCode::new`] plus the `with_*` methods; a future field +/// is then an added method, never a break. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub struct FedCode { /// The entity kind. pub kind: FedKind, @@ -243,6 +259,83 @@ pub struct FedCode { pub ml_dsa_65_pubkey_sha256: Option, } +impl OwnedNode { + /// A node the owner controls, for embedding in a v3 code. + /// + /// `transport_pubkey_ed25519_base64` is the node's **transport** key — + /// never the owner's federation key. See the type docs for what that + /// confusion cost in CIRISServer#335. + pub fn new( + key_id: impl Into, + transport_pubkey_ed25519_base64: impl Into, + ) -> Self { + Self { + key_id: key_id.into(), + transport_pubkey_ed25519_base64: transport_pubkey_ed25519_base64.into(), + } + } +} + +impl FedCode { + /// A code naming one entity by its Ed25519 federation key. + /// + /// Everything optional is added with the `with_*` methods, so a field + /// introduced later is an added method rather than a break + /// (CIRISVerify#274). + pub fn new( + kind: FedKind, + key_id: impl Into, + pubkey_ed25519_base64: impl Into, + ) -> Self { + Self { + kind, + key_id: key_id.into(), + pubkey_ed25519_base64: pubkey_ed25519_base64.into(), + transport_hint: None, + alias_hint: None, + group_key_id: None, + owned_nodes: Vec::new(), + ml_dsa_65_pubkey_sha256: None, + } + } + + /// A public base URL the holder suggests for reaching them. + #[must_use] + pub fn with_transport_hint(mut self, hint: impl Into) -> Self { + self.transport_hint = Some(hint.into()); + self + } + + /// A display name. Not signed, not PII-bearing — display only. + #[must_use] + pub fn with_alias_hint(mut self, alias: impl Into) -> Self { + self.alias_hint = Some(alias.into()); + self + } + + /// The group's `*_key_id`, for `family` / `community` codes. + #[must_use] + pub fn with_group_key_id(mut self, group_key_id: impl Into) -> Self { + self.group_key_id = Some(group_key_id.into()); + self + } + + /// The owner's nodes, so a contact resolves with no directory. + #[must_use] + pub fn with_owned_nodes(mut self, nodes: Vec) -> Self { + self.owned_nodes = nodes; + self + } + + /// `sha256(ml_dsa_65_pubkey)`, lowercase hex — without it the code names + /// a key that cannot be written to `federation_keys` (CIRISVerify#272). + #[must_use] + pub fn with_ml_dsa_65_pubkey_sha256(mut self, digest: impl Into) -> Self { + self.ml_dsa_65_pubkey_sha256 = Some(digest.into()); + self + } +} + /// fedcode encode/decode failures. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] @@ -334,6 +427,77 @@ fn prefix_for(fc: &FedCode) -> &'static str { } } +/// A hybrid identity admitted from a fedcode — **constructible only by +/// passing the commitment check** (CIRISVerify#274). +/// +/// [`verify_pulled_ml_dsa_65_pubkey`] is the right primitive but the wrong +/// *shape* for the job: it is a free function returning `Result<(), _>`, so +/// nothing structurally stops a host from pulling an ML-DSA body and +/// registering it without ever calling the check. The failure is silent — you +/// get a registered hybrid whose PQC half came from whoever answered the Pull. +/// +/// This type fixes the class instead of trusting each call site. Its fields +/// are private and its only constructor is [`admit`](Self::admit), which +/// consumes the code and the pulled bytes together and yields a value **only** +/// on match. A host that takes an `AdmittedHybridKey` as its registration +/// input cannot express the unchecked path. +/// +/// Same discipline as [`crate::federation_identity::Validity`]: private +/// fields, one fallible constructor, the type itself carrying the proof. +/// +/// ## What this does and does not prove +/// +/// It proves the ML-DSA half is **the one the code's minter committed to** — +/// not that whoever holds the Ed25519 key also holds this ML-DSA key. Nobody +/// cross-signs the two halves at registration, and they need not: persist +/// admits `algorithm: "hybrid"` only and every row must verify under **both** +/// signatures, so a holder of one half can never produce an admitting row. +/// Joint control is proven at **first use**, not at registration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdmittedHybridKey { + key_id: String, + pubkey_ed25519_base64: String, + ml_dsa_65_pubkey_base64: String, +} + +impl AdmittedHybridKey { + /// Admit a pulled ML-DSA-65 public key against the code that named it. + /// + /// This is the **only** way to build the type, so an unchecked + /// registration input does not exist to be passed anywhere. + /// + /// # Errors + /// [`FedCodeError::Malformed`] if the code carries no commitment (nothing + /// to bind against — fail closed rather than admit), or if the pulled key + /// does not match it. + pub fn admit(fc: &FedCode, pulled_ml_dsa_65_pubkey: &[u8]) -> Result { + verify_pulled_ml_dsa_65_pubkey(fc, pulled_ml_dsa_65_pubkey)?; + Ok(Self { + key_id: fc.key_id.clone(), + pubkey_ed25519_base64: fc.pubkey_ed25519_base64.clone(), + ml_dsa_65_pubkey_base64: b64().encode(pulled_ml_dsa_65_pubkey), + }) + } + + /// The federation `key_id` both halves register under. + #[must_use] + pub fn key_id(&self) -> &str { + &self.key_id + } + + /// The classical half, as carried by the code. + #[must_use] + pub fn pubkey_ed25519_base64(&self) -> &str { + &self.pubkey_ed25519_base64 + } + + /// The PQC half, **verified against the code's commitment**. + #[must_use] + pub fn ml_dsa_65_pubkey_base64(&self) -> &str { + &self.ml_dsa_65_pubkey_base64 + } +} + /// Check a pulled ML-DSA-65 public key against a code's commitment /// (CIRISVerify#272). /// @@ -1236,3 +1400,100 @@ mod pqc_pull { assert!(format!("{err}").contains("MUST NOT be admitted"), "{err}"); } } + +/// The enforcing-shape follow-ups from CIRISVerify#274. +#[cfg(test)] +mod admission_shape { + use super::*; + + fn pulled() -> Vec { + vec![0xABu8; 1952] + } + + fn digest_of(b: &[u8]) -> String { + Sha256::digest(b) + .iter() + .map(|x| format!("{x:02x}")) + .collect() + } + + fn code(pqc: Option) -> FedCode { + let fc = FedCode::new( + FedKind::User, + "eric-moore-a1b2c3", + b64().encode([0x11u8; PUBKEY_RAW_LEN]), + ); + match pqc { + Some(d) => fc.with_ml_dsa_65_pubkey_sha256(d), + None => fc, + } + } + + /// A matching pull yields the admitted pair, carrying BOTH halves. + #[test] + fn admit_yields_both_halves_on_match() { + let p = pulled(); + let admitted = AdmittedHybridKey::admit(&code(Some(digest_of(&p))), &p).unwrap(); + assert_eq!(admitted.key_id(), "eric-moore-a1b2c3"); + assert_eq!( + admitted.pubkey_ed25519_base64(), + b64().encode([0x11u8; PUBKEY_RAW_LEN]) + ); + assert_eq!(admitted.ml_dsa_65_pubkey_base64(), b64().encode(&p)); + } + + /// **The point of the type.** A substituted Key Pull produces no value at + /// all — so there is nothing to hand a `register_federation_key`, and the + /// silent-unchecked-registration path is unconstructible rather than + /// merely discouraged. + #[test] + fn a_substituted_pull_yields_no_value_to_register() { + let real = pulled(); + let err = + AdmittedHybridKey::admit(&code(Some(digest_of(&real))), &[0xCDu8; 1952]).unwrap_err(); + assert!(format!("{err}").contains("does not match"), "{err}"); + } + + /// A v1/v2 code is not a hybrid registration path at all. + #[test] + fn a_code_without_a_commitment_admits_nothing() { + let err = AdmittedHybridKey::admit(&code(None), &pulled()).unwrap_err(); + assert!(format!("{err}").contains("MUST NOT be admitted"), "{err}"); + } + + /// The builders reproduce what struct literals used to express — including + /// a code that emits byte-identical v2 when nothing optional is set. + #[test] + fn builders_cover_the_literal_shape() { + let plain = FedCode::new( + FedKind::User, + "u-aaaa", + b64().encode([0x11u8; PUBKEY_RAW_LEN]), + ); + assert!(encode(&plain).unwrap().starts_with("CIRIS-V2-")); + + let full = FedCode::new( + FedKind::Family, + "f-aaaa", + b64().encode([0x11u8; PUBKEY_RAW_LEN]), + ) + .with_transport_hint("https://example.invalid") + .with_alias_hint("Family") + .with_group_key_id("g-aaaa"); + assert_eq!(decode(&encode(&full).unwrap()).unwrap(), full); + + let with_nodes = FedCode::new( + FedKind::User, + "u-bbbb", + b64().encode([0x11u8; PUBKEY_RAW_LEN]), + ) + .with_owned_nodes(vec![OwnedNode::new( + "laptop-aaaa", + b64().encode([0x22u8; PUBKEY_RAW_LEN]), + )]) + .with_ml_dsa_65_pubkey_sha256(digest_of(&pulled())); + let back = decode(&encode(&with_nodes).unwrap()).unwrap(); + assert_eq!(back, with_nodes); + assert_eq!(back.owned_nodes.len(), 1); + } +}