diff --git a/CLAUDE.md b/CLAUDE.md index 744e401b..087aeed3 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.0.0** — see [Release history](#release-history) for this and every prior entry.)* +*(Current release: **14.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`). +- **14.1.0** — **a false alarm that also disabled the real alarm (#224), and a user code that can carry its own nodes (#269)**. **(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. - **13.6.1** — **docs: the epoch-binding is CONTINGENT, and CC's Position record says on what**. CIRISConstitution rc4.2 (`1561fb1`) appended an informative **Position** paragraph to CC 5.4.6 — the CIRISVerify#262/#91 prior-art record — which names the nearest admissible multi-hop relaxation (**blinded retained state: Tor v3 / I2P b33**) and attaches the field's rotation rule: **rotation clocks must be global, never group-event-driven.** That lands directly on code verify owns. `derive_destination` rotates on the **MLS epoch**, which advances on Add/Remove and is therefore *exactly* group-event-driven. v13.6.0 documented that as *"a feature here and a defect there"* — true, but incomplete in a misleading direction: it reads as *epoch-binding is right*, when the correct claim is **epoch-binding is right because nothing is emitted**. Under any multi-hop relaxation the derivation survives and its *schedule* does not. Recorded at `derive_destination` (where an implementer of that amendment will actually look) and in `announce_policy`'s open-questions note. Docs-only; no derivation, constant or vector moves. Also of note from the same record, for anyone reading the positioning rather than the rule: the design sits in the **zero-emission / membership-concealment corner** with the MCON impossibility floor (Vasserman et al., CCS 2009) satisfied *entirely by members*, and buys back the corner's two known prices — the **two-plane split** (public identity plane, derived group plane; derivation replaces discovery, dissolving the darknet-bootstrap problem) and **determinism replacing coordination** (the member-relay ALM tree is a pure function every member computes identically, recovering ⌈log_k N⌉ fan-out without a coordinator that would have to learn the group exists — CC 6.1.6). The ballot corollary is recorded without minting conformance surface: *a classical secret ballot hides the vote; structural invisibility hides the election.* - **13.6.0** — **CC 5.4.6 ruled (CIRISConstitution#91): the announce prohibition binds the EMISSION, so a targeted announce inherits it — encoded, not merely noted (#262)**. CIRISEdge found scoped addresses are **one hop by construction**: edge supplies an explicit destination hash, leviculum correctly refuses to announce those (an announce for a caller-supplied hash emits a `destination_hash` that no Python-RNS peer recomputes), so no transport node learns a path. Edge proposed per-group **identities** — let RNS compute the hash natively, making the destination announceable and multi-hop — which would have demoted `derive_destination` from *address* to *name*, and **correctly refused to pick alone**, filing #262 to couple it with #259's still-open label rather than shipping a wire fact unilaterally. My first read foreclosed it on CC 5.4.6's flat *"MUST NOT emit a Reticulum announce"* + the fail-secure clause forbidding fallback-to-announce *to recover reachability*. **That read was too fast**: a **targeted** announce iterated over the roster leaks nothing to an outsider, so it satisfies 5.4.6's purposive gloss (*"the announce **that would reveal**…"*) while reading against the flat text — and CC contained **no notion of a targeted announce anywhere**, having been drafted when announce ≡ broadcast. Rather than resolve a ratified rule locally to unblock a downstream — the exact failure `Gating::Normative { authority }` exists to prevent — it went to CC. **Ruled: the packet.** Three legs, now in-clause: the purposive gloss is *rationale, not exception* (no directed announce on RNS can satisfy it — multi-hop path learning **is** outsider observation, and retained path state is precisely the edge class the subpoena framing promises does not exist); the flat MUST NOT was never broadcast-era shorthand (the same section bans the *targeted, non-broadcast* per-destination query in the same breath); and a directed announce **trades a claimable guarantee for an unclaimable one** — *no emission exists* (structural) for *emissions exist but resist analysis* (traffic-analysis privacy, which CEG/RET declines to claim). New `announce_policy::CohortScope` encodes the partition — the four below-federation tiers may not announce, the four Commons tiers may — with **no addressing-mode parameter**, since the ruling binds the emission; a test pins that so adding one is a visible act. `wire_may_announce` **fails closed on an unknown scope**, per 5.4.6's *"MUST fail toward suppression, never toward announce"*. Tagged `Normative(CC 5.4.6, ratified CIRISConstitution#91)`. **The ruling also resolved a dilemma neither issue reached:** 5.4.6's derivation is epoch-bound, so under the announceable reading every Add/Remove forces a synchronized roster-wide re-announce **wave** (leaking cardinality, timing, churn), while not rotating leaves a removed member holding every peer's addressing forever. Under the packet reading there is no wave because there is no emission — **epoch-binding is a feature here and a defect there**, which is part of why the alternative lost. v13.4.0's derivation and v13.5.0's `DESTINATION_EXPORTER_LABEL` are confirmed correctly targeted; multi-hop scoped reach stays open on the amendment plane with its bar stated (no outsider-observable emission, no outsider-retained path state, no epoch-correlated wave). 5 tests; 1373 workspace green, clippy clean. diff --git a/Cargo.lock b/Cargo.lock index 615a523f..d4333fbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,7 +668,7 @@ dependencies = [ [[package]] name = "ciris-build-tool" -version = "14.0.0" +version = "14.1.0" dependencies = [ "anyhow", "base64 0.21.7", @@ -690,7 +690,7 @@ dependencies = [ [[package]] name = "ciris-crypto" -version = "14.0.0" +version = "14.1.0" dependencies = [ "chacha20poly1305", "criterion", @@ -722,7 +722,7 @@ dependencies = [ [[package]] name = "ciris-keyring" -version = "14.0.0" +version = "14.1.0" dependencies = [ "aes-gcm", "async-trait", @@ -762,7 +762,7 @@ dependencies = [ [[package]] name = "ciris-manifest-tool" -version = "14.0.0" +version = "14.1.0" dependencies = [ "anyhow", "chrono", @@ -779,7 +779,7 @@ dependencies = [ [[package]] name = "ciris-tpm-plugin" -version = "14.0.0" +version = "14.1.0" dependencies = [ "sha2", "tracing", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "ciris-verify-core" -version = "14.0.0" +version = "14.1.0" dependencies = [ "android_system_properties", "async-trait", @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "ciris-verify-ffi" -version = "14.0.0" +version = "14.1.0" dependencies = [ "aes-gcm", "android_logger", diff --git a/Cargo.toml b/Cargo.toml index 80488355..39e86093 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "14.0.0" +version = "14.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 d708118d..a89fbc50 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.0.0" +__version__ = "14.1.0" __all__ = [ "CIRISVerify", "MockCIRISVerify", diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 968f3449..7b99c9aa 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.0.0" +version = "14.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 197b85ef..e198aee0 100644 --- a/evidence/cc_impl.tsv +++ b/evidence/cc_impl.tsv @@ -70,3 +70,4 @@ UNASSIGNED CLM-subject-binding-keyrecord CIRISVerify src/ciris-verify-core/src/f UNASSIGNED 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 diff --git a/src/ciris-verify-core/src/bin/ciris_verify.rs b/src/ciris-verify-core/src/bin/ciris_verify.rs index f3f51782..c3205c82 100644 --- a/src/ciris-verify-core/src/bin/ciris_verify.rs +++ b/src/ciris-verify-core/src/bin/ciris_verify.rs @@ -2538,6 +2538,7 @@ fn run_fedcode_new(a: FedcodeNew) { let key_id = fedcode::derive_key_id(&a.label, &ed_pub); let fc = FedCode { + owned_nodes: Vec::new(), kind, key_id: key_id.clone(), pubkey_ed25519_base64: base64::engine::general_purpose::STANDARD.encode(&ed_pub), diff --git a/src/ciris-verify-core/src/fedcode.rs b/src/ciris-verify-core/src/fedcode.rs index b2e8cea0..f576a85b 100644 --- a/src/ciris-verify-core/src/fedcode.rs +++ b/src/ciris-verify-core/src/fedcode.rs @@ -45,9 +45,27 @@ pub const FEDCODE_VERSION_V2: u8 = 0x02; pub const FEDCODE_VERSION_V1: u8 = 0x01; const PREFIX_V2: &str = "CIRIS-V2-"; + +/// Binary-format version for a code that MAY embed the owner's nodes +/// (CIRISVerify#269). +/// +/// **Note the number.** The issue proposing this called it "fedcode v2", but +/// the wire format has been at v2 since the kind-tagged code shipped — so the +/// node-carrying format is **v3**. Minting it as "v2" would have collided with +/// a live encoding. +pub const FEDCODE_VERSION_V3: u8 = 0x03; + +const PREFIX_V3: &str = "CIRIS-V3-"; const PREFIX_V1: &str = "CIRIS-V1-"; const GROUP_SIZE: usize = 4; const MAX_FIELD_BYTES: usize = 255; + +/// Cap on embedded nodes in a v3 code (CIRISVerify#269). +/// +/// A fedcode is meant to be scannable as a QR and readable aloud; an unbounded +/// list makes it neither. 16 is well past any realistic owner's node count and +/// keeps the code inside a comfortable QR density. +const MAX_OWNED_NODES: usize = 16; const PUBKEY_RAW_LEN: usize = 32; const KEY_ID_HASH_LEN: usize = 32; const CRC_POLY: u16 = 0x1021; @@ -119,6 +137,37 @@ impl FedKind { } } +/// One node a user owns, as embedded in a v3 code (CIRISVerify#269). +/// +/// ## The field name is the safety property +/// +/// This carries the node's **transport** Ed25519 — never the owner's +/// federation key, and never the node's federation key. +/// +/// CIRISServer#335 is what the confusion cost in production: 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** — and the false rooting then *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: deriving from the federation +/// key yields `sha256(fed)[..16]`, an explicit-hash destination that +/// categorically **cannot be announced**, so no peer can ever self-learn a +/// route to it. +/// +/// [`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)] +pub struct OwnedNode { + /// The node's federation `key_id` — an identifier, not an address. + pub key_id: String, + /// The node's **transport** Ed25519 public key, base64 standard (raw 32 + /// bytes). This is what a destination is derived from; see the type docs. + pub transport_pubkey_ed25519_base64: String, +} + /// A decoded / to-be-encoded fedcode. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FedCode { @@ -134,6 +183,20 @@ pub struct FedCode { pub alias_hint: Option, /// For `family` / `community`: the group's `*_key_id`. Absent otherwise. pub group_key_id: Option, + /// **MAY** carry the owner's nodes, so a contact resolves with no + /// directory (CIRISVerify#269) — first contact, a QR across a table, an + /// air-gapped hand-off, a fresh install. + /// + /// Empty is valid and is the default: a code with no nodes degrades to the + /// v1/v2 directory path, and **encodes byte-identically to a v2 code**, so + /// nothing already issued changes. Only a non-empty list emits v3. + /// + /// Scope boundary: these are **lightnet** facts — federation-scope + /// identity that already announces publicly and carries no anonymity + /// claim. A code MUST NOT carry group-scoped material, whose destinations + /// are derived from `cached directory + per-group HKDF` and may not be + /// emitted at all (CC 5.4.6, ruled in CIRISConstitution#91). + pub owned_nodes: Vec, } /// fedcode encode/decode failures. @@ -211,7 +274,20 @@ fn sanitize_label(label: &str) -> String { /// [`FedCodeError::Malformed`] if a field is over-long or the pubkey is not 32 /// raw bytes of valid base64. pub fn encode(fc: &FedCode) -> Result { - Ok(format!("{PREFIX_V2}{}", group(&encode_body(fc)?))) + Ok(format!("{}{}", prefix_for(fc), group(&encode_body(fc)?))) +} + +/// The display prefix a code needs, chosen by CONTENT. +/// +/// A code with no embedded nodes stays `CIRIS-V2-` and encodes byte-identically +/// to before, so nothing already issued changes (CIRISVerify#269). Only a +/// non-empty node list emits `CIRIS-V3-`. +fn prefix_for(fc: &FedCode) -> &'static str { + if fc.owned_nodes.is_empty() { + PREFIX_V2 + } else { + PREFIX_V3 + } } /// Encode to the ungrouped QR form (`CIRIS-V2-XXXXXXXX…`). @@ -219,7 +295,7 @@ pub fn encode(fc: &FedCode) -> Result { /// # Errors /// As [`encode`]. pub fn encode_qr(fc: &FedCode) -> Result { - Ok(format!("{PREFIX_V2}{}", encode_body(fc)?)) + Ok(format!("{}{}", prefix_for(fc), encode_body(fc)?)) } fn encode_body(fc: &FedCode) -> Result { @@ -249,8 +325,22 @@ fn build_payload(fc: &FedCode) -> Result, FedCodeError> { ))); } + // Only a user code may carry nodes: "the owner's nodes" is meaningless for + // a node, and a group's destinations are group-scoped material a code MUST + // NOT carry at all (CC 5.4.6). Narrow now; widening later is additive. + if !fc.owned_nodes.is_empty() && fc.kind != FedKind::User { + return Err(FedCodeError::Malformed(format!( + "only a `user` code may embed owned nodes, got `{}`", + fc.kind.as_str() + ))); + } + let mut out = Vec::new(); - out.push(FEDCODE_VERSION_V2); + out.push(if fc.owned_nodes.is_empty() { + FEDCODE_VERSION_V2 + } else { + FEDCODE_VERSION_V3 + }); out.push(fc.kind.as_u8()); out.extend_from_slice(&Sha256::digest(key_id_bytes)); out.extend_from_slice(&pubkey_raw); @@ -259,6 +349,59 @@ fn build_payload(fc: &FedCode) -> Result, FedCodeError> { out.extend_from_slice(&encode_hint(fc.transport_hint.as_deref())?); out.extend_from_slice(&encode_hint(fc.alias_hint.as_deref())?); out.extend_from_slice(&encode_hint(fc.group_key_id.as_deref())?); + + // v3 tail: the owner's nodes. Absent entirely on v2, so the bytes above + // are unchanged for every code issued so far. + if !fc.owned_nodes.is_empty() { + if fc.owned_nodes.len() > MAX_OWNED_NODES { + return Err(FedCodeError::Malformed(format!( + "at most {MAX_OWNED_NODES} owned nodes, got {}", + fc.owned_nodes.len() + ))); + } + out.push(fc.owned_nodes.len() as u8); + for node in &fc.owned_nodes { + let id = node.key_id.as_bytes(); + if id.is_empty() || id.len() > MAX_FIELD_BYTES { + return Err(FedCodeError::Malformed(format!( + "node key_id must be 1..={MAX_FIELD_BYTES} bytes, got {}", + id.len() + ))); + } + let tp = b64() + .decode(node.transport_pubkey_ed25519_base64.as_bytes()) + .map_err(|e| { + FedCodeError::Malformed(format!( + "node `{}` transport pubkey is not valid base64: {e}", + node.key_id + )) + })?; + if tp.len() != PUBKEY_RAW_LEN { + return Err(FedCodeError::Malformed(format!( + "node `{}` transport pubkey must be {PUBKEY_RAW_LEN} raw bytes, got {}", + node.key_id, + tp.len() + ))); + } + // THE constraint (#269 / CIRISServer#335). Embedding the owner's + // federation key as a node's transport key yields + // `sha256(fed)[..16]` — an explicit-hash destination that can + // never be announced, so no peer can self-learn a route to it, and + // a node that believes it knows the peer never recovers. Refuse it + // at the encoder, where it is still cheap. + if tp == pubkey_raw { + return Err(FedCodeError::Malformed(format!( + "node `{}` transport pubkey equals the OWNER's federation key — \ + a destination derived from it is unannounceable and unreachable \ + (CIRISServer#335); embed the node's TRANSPORT key", + node.key_id + ))); + } + out.push(id.len() as u8); + out.extend_from_slice(id); + out.extend_from_slice(&tp); + } + } Ok(out) } @@ -294,7 +437,7 @@ pub fn decode(code: &str) -> Result { let version = payload[0]; let mut offset = 1; let kind = match version { - FEDCODE_VERSION_V2 => { + FEDCODE_VERSION_V2 | FEDCODE_VERSION_V3 => { let k = FedKind::from_u8(*payload.get(offset).ok_or_else(trunc)?)?; offset += 1; k @@ -303,7 +446,7 @@ pub fn decode(code: &str) -> Result { FEDCODE_VERSION_V1 => FedKind::Node, other => { return Err(FedCodeError::InvalidVersion(format!( - "binary version 0x{other:02x}; supported: 0x01 (node), 0x02" + "binary version 0x{other:02x}; supported: 0x01 (node), 0x02, 0x03" ))) }, }; @@ -321,13 +464,53 @@ pub fn decode(code: &str) -> Result { offset = off; let (alias_hint, off) = read_hint(payload, offset)?; offset = off; - // group_key_id only exists in v2; tolerate its absence (v1). - let group_key_id = if version == FEDCODE_VERSION_V2 && offset < payload.len() { - read_hint(payload, offset)?.0 + // group_key_id exists from v2 onward; tolerate its absence (v1). + let group_key_id = if version >= FEDCODE_VERSION_V2 && offset < payload.len() { + let (g, off) = read_hint(payload, offset)?; + offset = off; + g } else { None }; + // v3 tail: the owner's nodes (CIRISVerify#269). Absent on v1/v2. + let owned_nodes = if version == FEDCODE_VERSION_V3 { + let count = usize::from(*payload.get(offset).ok_or_else(trunc)?); + offset += 1; + if count > MAX_OWNED_NODES { + return Err(FedCodeError::Malformed(format!( + "at most {MAX_OWNED_NODES} owned nodes, got {count}" + ))); + } + let mut nodes = Vec::with_capacity(count); + for _ in 0..count { + let (key_id, off) = read_length_prefixed(payload, offset)?; + offset = off; + if payload.len() < offset + PUBKEY_RAW_LEN { + return Err(trunc()); + } + let tp = &payload[offset..offset + PUBKEY_RAW_LEN]; + offset += PUBKEY_RAW_LEN; + // Refuse on DECODE as well as encode: a code minted by another + // implementation is exactly the case the encoder cannot police, + // and this is the mistake that cost CIRISServer#335. + if tp == pubkey_raw { + return Err(FedCodeError::Malformed(format!( + "node `{key_id}` transport pubkey equals the owner's federation \ + key — a destination derived from it is unannounceable and \ + unreachable (CIRISServer#335)" + ))); + } + nodes.push(OwnedNode { + key_id, + transport_pubkey_ed25519_base64: b64().encode(tp), + }); + } + nodes + } else { + Vec::new() + }; + Ok(FedCode { kind, key_id, @@ -335,6 +518,7 @@ pub fn decode(code: &str) -> Result { transport_hint, alias_hint, group_key_id, + owned_nodes, }) } @@ -343,7 +527,7 @@ fn trunc() -> FedCodeError { } fn strip_prefix(cleaned: &str) -> Result { - for p in [PREFIX_V2, PREFIX_V1] { + for p in [PREFIX_V3, PREFIX_V2, PREFIX_V1] { if let Some(rest) = cleaned.strip_prefix(p) { return Ok(rest.to_string()); } @@ -354,7 +538,7 @@ fn strip_prefix(cleaned: &str) -> Result { } } Err(FedCodeError::Malformed(format!( - "not a CIRIS fedcode (expected {PREFIX_V2:?} or {PREFIX_V1:?})" + "not a CIRIS fedcode (expected {PREFIX_V3:?}, {PREFIX_V2:?} or {PREFIX_V1:?})" ))) } @@ -482,6 +666,7 @@ mod tests { fn sample(kind: FedKind) -> FedCode { FedCode { + owned_nodes: Vec::new(), kind, key_id: "eric-moore-k7f3qd2pza".into(), pubkey_ed25519_base64: pk(9), @@ -567,3 +752,128 @@ mod tests { )); } } + +/// v3 — a user code that MAY embed its owned nodes (CIRISVerify#269). +#[cfg(test)] +mod owned_nodes { + use super::*; + + fn pk(b: u8) -> String { + b64().encode([b; PUBKEY_RAW_LEN]) + } + + fn user(nodes: Vec) -> FedCode { + FedCode { + kind: FedKind::User, + key_id: "eric-moore-a1b2c3".into(), + pubkey_ed25519_base64: pk(0x11), + transport_hint: None, + alias_hint: Some("Eric".into()), + group_key_id: None, + owned_nodes: nodes, + } + } + + fn node(id: &str, b: u8) -> OwnedNode { + OwnedNode { + key_id: id.into(), + transport_pubkey_ed25519_base64: pk(b), + } + } + + /// **An empty node list changes nothing.** A code with no nodes still + /// encodes as v2, byte-identically to before, so nothing already issued + /// moves — the `MAY` in the ask is free. + #[test] + fn no_nodes_still_encodes_as_v2() { + let code = encode(&user(vec![])).unwrap(); + assert!(code.starts_with("CIRIS-V2-"), "{code}"); + let back = decode(&code).unwrap(); + assert!(back.owned_nodes.is_empty()); + assert_eq!(back, user(vec![])); + } + + /// A non-empty list emits v3 and round-trips. + #[test] + fn nodes_round_trip_under_v3() { + let fc = user(vec![node("laptop-aaaa", 0x22), node("phone-bbbb", 0x33)]); + let code = encode(&fc).unwrap(); + assert!(code.starts_with("CIRIS-V3-"), "{code}"); + assert_eq!(decode(&code).unwrap(), fc); + } + + /// **The constraint, refused at the ENCODER** — CIRISServer#335. + /// + /// Embedding the owner's federation key as a node's transport key derives + /// `sha256(fed)[..16]`, an explicit-hash destination that can never be + /// announced, so no peer self-learns a route and the false rooting then + /// prevents recovery. + #[test] + fn embedding_the_owners_own_key_as_a_transport_key_is_refused() { + let mut fc = user(vec![node("laptop-aaaa", 0x22)]); + fc.owned_nodes[0].transport_pubkey_ed25519_base64 = fc.pubkey_ed25519_base64.clone(); + let err = encode(&fc).unwrap_err(); + assert!(format!("{err}").contains("OWNER's federation key"), "{err}"); + } + + /// …and refused at the DECODER too, because a code minted by another + /// implementation is exactly the case an encoder cannot police. + #[test] + fn a_foreign_code_with_the_owners_key_is_refused_on_decode() { + // Mint it by hand, bypassing our own encoder's refusal. + let mut payload = Vec::new(); + payload.push(FEDCODE_VERSION_V3); + payload.push(FedKind::User.as_u8()); + let key_id = b"eric-moore-a1b2c3"; + payload.extend_from_slice(&Sha256::digest(key_id)); + payload.extend_from_slice(&[0x11; PUBKEY_RAW_LEN]); // owner pubkey + payload.push(key_id.len() as u8); + payload.extend_from_slice(key_id); + payload.extend_from_slice(&[0x00, 0x00, 0x00]); // three absent hints + payload.push(1); // one node + payload.push(4); + payload.extend_from_slice(b"nodeX"); + payload.truncate(payload.len() - 1); // key_id was 4 bytes: "node" + payload.extend_from_slice(&[0x11; PUBKEY_RAW_LEN]); // == owner's key + + let crc = crc16_ccitt(&payload); + payload.push((crc >> 8) as u8); + payload.push((crc & 0xFF) as u8); + let code = format!("{PREFIX_V3}{}", b32_no_pad_encode(&payload)); + + let err = decode(&code).unwrap_err(); + assert!(format!("{err}").contains("owner's federation"), "{err}"); + } + + /// 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). + #[test] + fn only_a_user_code_may_embed_nodes() { + for kind in [ + FedKind::Node, + FedKind::Agent, + FedKind::Family, + FedKind::Community, + ] { + let mut fc = user(vec![node("laptop-aaaa", 0x22)]); + fc.kind = kind; + assert!(encode(&fc).is_err(), "{kind:?} must not carry nodes"); + } + } + + /// The list is bounded — a fedcode has to stay scannable. + #[test] + fn the_node_list_is_bounded() { + let many: Vec<_> = (0..=MAX_OWNED_NODES) + .map(|i| node(&format!("n{i}-aaaa"), 0x22 + i as u8)) + .collect(); + assert!(encode(&user(many)).is_err()); + } + + /// v1 and v2 codes still decode unchanged. + #[test] + fn older_versions_still_decode() { + let v2 = encode(&user(vec![])).unwrap(); + assert_eq!(decode(&v2).unwrap().owned_nodes.len(), 0); + } +} diff --git a/src/ciris-verify-core/src/federation_identity.rs b/src/ciris-verify-core/src/federation_identity.rs index 25945f5e..a17f8ba3 100644 --- a/src/ciris-verify-core/src/federation_identity.rs +++ b/src/ciris-verify-core/src/federation_identity.rs @@ -252,6 +252,7 @@ pub async fn create_federation_identity( transport_hint: None, alias_hint: label.map(str::to_string), group_key_id: None, + owned_nodes: Vec::new(), }) .map_err(|e| VerifyError::IntegrityError { message: format!("encode fedcode: {e}"), diff --git a/src/ciris-verify-core/src/security/file_integrity.rs b/src/ciris-verify-core/src/security/file_integrity.rs index b803cbf9..5cbd69b5 100644 --- a/src/ciris-verify-core/src/security/file_integrity.rs +++ b/src/ciris-verify-core/src/security/file_integrity.rs @@ -55,9 +55,57 @@ impl std::fmt::Display for FileCheckStatus { } } +/// Which construction, if any, reproduces a manifest's stored `manifest_hash` +/// (CIRISVerify#224). +/// +/// This repository contains **three** different `manifest_hash` constructions +/// — concatenated per-file hashes (here), concatenated per-*function* hashes +/// (`ciris-manifest-tool`), and SHA-256 over serde-JSON bytes +/// (`ciris-build-tool`). A manifest produced by one and checked by another can +/// never match, by construction. +/// +/// The old code recomputed exactly one of them, **self-diagnosed the benign +/// cause in its own warning**, and still reported the result as a tampering- +/// shaped failure. Worse, `check_full` then returned early with +/// `files_checked: 0`, so the per-file hashes — the actual integrity control — +/// were never checked at all. A false alarm that also disables the real alarm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ManifestHashCheck { + /// Reproduced by concatenating the per-file hash strings in sorted order. + VerifiedConcatenated, + /// Reproduced as SHA-256 over the serde-JSON bytes of the file map. + VerifiedJson, + /// **Not reproduced by any construction this build knows.** + /// + /// Deliberately not called "mismatch": with only `files` and + /// `manifest_hash` in hand, a fourth unknown construction is + /// *indistinguishable* from tampering, so asserting either would be + /// manufacturing a verdict from a measurement we cannot make + /// (`MISSION.md` §1.4 — the same distinction v13.3.0 drew for revocation). + /// + /// The per-file hashes are the control that can actually answer the + /// question, and they are now checked regardless. + Unrecognized, +} + +/// Deserialization default for [`FileIntegrityResult::manifest_hash_check`] +/// on payloads produced before CIRISVerify#224 — those carry no such field, +/// and "we do not know" is the honest value for them. +fn manifest_hash_check_default() -> ManifestHashCheck { + ManifestHashCheck::Unrecognized +} + /// Result of a file integrity check. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FileIntegrityResult { + /// Which construction reproduced the stored `manifest_hash`, if any + /// (CIRISVerify#224). [`ManifestHashCheck::Unrecognized`] is a + /// measurement, not a tampering verdict — read `per_file_results` for + /// that. + #[serde(default = "manifest_hash_check_default")] + pub manifest_hash_check: ManifestHashCheck, /// Whether all checked files passed integrity verification. pub integrity_valid: bool, /// Total files in manifest. @@ -171,69 +219,62 @@ fn hash_file(path: &Path) -> std::io::Result { } /// Verify the manifest's own integrity (manifest_hash field). -fn verify_manifest_integrity(manifest: &FileManifest) -> bool { - tracing::info!( - "verify_manifest_integrity: starting with {} files, stored_hash='{}'", - manifest.files.len(), - &manifest.manifest_hash - ); - - // Check for empty manifest +/// Which construction reproduces the manifest's stored `manifest_hash`. +/// +/// Tries every construction this repository actually produces, rather than +/// one of them — see [`ManifestHashCheck`] for why that matters. +/// +/// # Errors +/// A structurally malformed manifest (no files, or an empty hash) returns +/// `None`. That IS a hard failure: it is not a disagreement about algorithms, +/// it is a document that cannot be checked at all. +fn verify_manifest_integrity(manifest: &FileManifest) -> Option { if manifest.files.is_empty() { - tracing::error!("verify_manifest_integrity: FAILED - manifest has 0 files!"); - return false; + tracing::error!("manifest integrity: manifest has 0 files"); + return None; } - - // Check for empty/missing manifest hash if manifest.manifest_hash.is_empty() { - tracing::error!("verify_manifest_integrity: FAILED - manifest_hash is empty!"); - return false; + tracing::error!("manifest integrity: manifest_hash is empty"); + return None; } - let mut hasher = Sha256::new(); - // Hash all file hashes in sorted order (BTreeMap is already sorted) - let mut hash_count = 0; - for (path, hash) in manifest.files.iter() { - hasher.update(hash.as_bytes()); - hash_count += 1; - // Log first 3 entries for debugging - if hash_count <= 3 { - tracing::debug!( - "verify_manifest_integrity: hashing file #{}: path='{}', hash='{}'", - hash_count, - path, - &hash[..std::cmp::min(16, hash.len())] - ); - } - } - let computed = hex::encode(hasher.finalize()); - - // Strip "sha256:" prefix from stored hash if present - let stored_clean = manifest + let stored = manifest .manifest_hash .strip_prefix("sha256:") .unwrap_or(&manifest.manifest_hash); - let matches = super::constant_time_eq(computed.as_bytes(), stored_clean.as_bytes()); - - tracing::info!( - "verify_manifest_integrity: hashed {} files, computed='{}', stored='{}', matches={}", - hash_count, - &computed[..std::cmp::min(16, computed.len())], - &stored_clean[..std::cmp::min(16, stored_clean.len())], - matches - ); + // (1) concatenated per-file hash strings, sorted (BTreeMap order). + let mut hasher = Sha256::new(); + for hash in manifest.files.values() { + hasher.update(hash.as_bytes()); + } + if super::constant_time_eq(hex::encode(hasher.finalize()).as_bytes(), stored.as_bytes()) { + return Some(ManifestHashCheck::VerifiedConcatenated); + } - if !matches { - tracing::warn!( - "verify_manifest_integrity: HASH MISMATCH! This could mean:\n\ - 1. Registry uses different hash algorithm (JSON hash vs concatenated values)\n\ - 2. File ordering differs between registry and local\n\ - 3. Manifest was modified in transit" - ); + // (2) SHA-256 over the serde-JSON bytes of the file map — the shape + // `ciris-build-tool` emits. Checking it is the difference between + // "verified under the other construction" and the old code's + // self-diagnosed guess. + if let Ok(json) = serde_json::to_vec(&manifest.files) { + if super::constant_time_eq( + hex::encode(Sha256::digest(&json)).as_bytes(), + stored.as_bytes(), + ) { + return Some(ManifestHashCheck::VerifiedJson); + } } - matches + // Neither. NOT reported as tampering: a fourth construction is + // indistinguishable from tampering with the data in hand, and the per-file + // hashes are what can actually answer the question. + tracing::warn!( + stored = %&stored[..std::cmp::min(16, stored.len())], + files = manifest.files.len(), + "manifest_hash not reproduced by any known construction — proceeding to \ + the per-file check, which is the control that can detect tampering" + ); + Some(ManifestHashCheck::Unrecognized) } /// Load a manifest from a JSON file. @@ -263,9 +304,14 @@ pub fn check_full(manifest: &FileManifest, agent_root: &Path) -> FileIntegrityRe ); // First, verify the manifest itself - if !verify_manifest_integrity(manifest) { - tracing::error!("check_full: manifest integrity verification FAILED"); + // Only a STRUCTURALLY malformed manifest short-circuits now. An + // unrecognized manifest_hash construction does not: it is not evidence of + // tampering, and returning early here skipped the per-file check entirely + // — a false alarm that also disabled the real alarm (CIRISVerify#224). + let Some(manifest_hash_check) = verify_manifest_integrity(manifest) else { + tracing::error!("check_full: manifest is malformed (no files, or empty manifest_hash)"); return FileIntegrityResult { + manifest_hash_check: ManifestHashCheck::Unrecognized, integrity_valid: false, total_files: manifest.files.len(), files_checked: 0, @@ -279,7 +325,7 @@ pub fn check_full(manifest: &FileManifest, agent_root: &Path) -> FileIntegrityRe per_file_results: BTreeMap::new(), unexpected_files: Vec::new(), }; - } + }; tracing::info!("check_full: manifest integrity OK"); let mut files_checked = 0usize; @@ -332,6 +378,7 @@ pub fn check_full(manifest: &FileManifest, agent_root: &Path) -> FileIntegrityRe }; FileIntegrityResult { + manifest_hash_check, integrity_valid, total_files: manifest.files.len(), files_checked, @@ -355,8 +402,9 @@ pub fn check_spot(manifest: &FileManifest, agent_root: &Path, count: usize) -> F use rand::seq::SliceRandom; // Verify manifest integrity first - if !verify_manifest_integrity(manifest) { + let Some(manifest_hash_check) = verify_manifest_integrity(manifest) else { return FileIntegrityResult { + manifest_hash_check: ManifestHashCheck::Unrecognized, integrity_valid: false, total_files: manifest.files.len(), files_checked: 0, @@ -370,7 +418,7 @@ pub fn check_spot(manifest: &FileManifest, agent_root: &Path, count: usize) -> F per_file_results: BTreeMap::new(), unexpected_files: Vec::new(), }; - } + }; let file_list: Vec<(&String, &String)> = manifest.files.iter().collect(); let check_count = count.min(file_list.len()); @@ -419,6 +467,7 @@ pub fn check_spot(manifest: &FileManifest, agent_root: &Path, count: usize) -> F }; FileIntegrityResult { + manifest_hash_check, integrity_valid, total_files: manifest.files.len(), files_checked: check_count, @@ -482,13 +531,13 @@ pub fn check_available(manifest: &FileManifest, agent_root: &Path) -> FileIntegr // First, verify the manifest itself tracing::info!("check_available: verifying manifest integrity..."); - if !verify_manifest_integrity(manifest) { + let Some(manifest_hash_check) = verify_manifest_integrity(manifest) else { tracing::error!( - "check_available: MANIFEST INTEGRITY FAILED - returning files_checked=0\n \ - This means the hash of concatenated file hashes doesn't match manifest_hash.\n \ - Check if registry stores a different hash format." + "check_available: manifest is malformed (no files, or empty manifest_hash) — \ + returning files_checked=0" ); return FileIntegrityResult { + manifest_hash_check: ManifestHashCheck::Unrecognized, integrity_valid: false, total_files: manifest.files.len(), files_checked: 0, @@ -502,7 +551,7 @@ pub fn check_available(manifest: &FileManifest, agent_root: &Path) -> FileIntegr per_file_results: BTreeMap::new(), unexpected_files: Vec::new(), }; - } + }; tracing::info!("check_available: manifest integrity PASSED, now checking files..."); let mut files_checked = 0usize; @@ -587,6 +636,7 @@ pub fn check_available(manifest: &FileManifest, agent_root: &Path) -> FileIntegr ); FileIntegrityResult { + manifest_hash_check, integrity_valid, total_files: manifest.files.len(), files_checked, @@ -842,18 +892,99 @@ mod tests { assert_eq!(result.files_failed, 0); } + /// **The #224 scenario itself.** A manifest whose `manifest_hash` was + /// produced by the OTHER construction now verifies, instead of logging a + /// tampering-shaped ERROR 14 times in four hours. #[test] - fn test_manifest_tamper_detection() { + fn the_json_construction_verifies_instead_of_alarming() { let dir = create_test_dir(); let mut manifest = generate_manifest(dir.path(), "2.0.0").unwrap(); - // Tamper with the manifest hash - manifest.manifest_hash = - "0000000000000000000000000000000000000000000000000000000000000000".to_string(); + // Restate the hash the way `ciris-build-tool` does: sha256 over the + // serde-JSON bytes, rather than over concatenated file hashes. + let json = serde_json::to_vec(&manifest.files).unwrap(); + manifest.manifest_hash = format!("sha256:{}", hex::encode(Sha256::digest(&json))); + assert_eq!( + verify_manifest_integrity(&manifest), + Some(ManifestHashCheck::VerifiedJson), + "the other construction must VERIFY, not read as tampering" + ); + let result = check_full(&manifest, dir.path()); + assert!(result.integrity_valid); + assert!(result.files_checked > 0); + } + + /// **CIRISVerify#224 — a corrupt `manifest_hash` is SURFACED, not conflated + /// with tampering, and no longer disables the real check.** + /// + /// This test asserted the old contract: any unmatched `manifest_hash` + /// meant `integrity_valid: false` with `files_checked: 0`. That contract + /// is wrong in both directions. + /// + /// It is too strict: three different `manifest_hash` constructions exist + /// in this repository, so a manifest produced by one and checked by + /// another never matched — 14 tampering-shaped ERRORs in a 4h window on + /// `datum`, all benign. + /// + /// And it is too weak, which matters more: returning early meant the + /// PER-FILE hashes were never checked, so the actual integrity control was + /// silently skipped on every such manifest (the same symptom #176 reports + /// as "L4 file integrity will be skipped"). A false alarm that also + /// disabled the real alarm. + /// + /// Note what altering `manifest_hash` alone actually achieves: nothing. + /// The per-file hashes are untouched, so the files are still checked + /// against the real values — and an attacker who altered those would + /// simply recompute `manifest_hash` to match, which this check never + /// caught either. It is a self-consistency checksum, not an + /// authentication; the manifest's trustworthiness comes from its + /// signature. + #[test] + fn corrupt_manifest_hash_is_reported_without_skipping_the_file_check() { + let dir = create_test_dir(); + let mut manifest = generate_manifest(dir.path(), "2.0.0").unwrap(); + manifest.manifest_hash = "0".repeat(64); + + let result = check_full(&manifest, dir.path()); + + // The corruption is SURFACED … + assert_eq!(result.manifest_hash_check, ManifestHashCheck::Unrecognized); + // … and the control that can actually detect tampering RAN. + assert!( + result.files_checked > 0, + "the per-file check must not be skipped — that is the real control" + ); + assert_eq!(result.files_failed, 0); + } + + /// The control that matters still fails closed: alter a FILE and the + /// per-file check catches it, which is the thing `manifest_hash` never + /// could. + #[test] + fn a_tampered_file_is_still_caught() { + let dir = create_test_dir(); + let manifest = generate_manifest(dir.path(), "2.0.0").unwrap(); + let victim = manifest.files.keys().next().unwrap().clone(); + std::fs::write(dir.path().join(&victim), "# tampered\n").unwrap(); + + let result = check_full(&manifest, dir.path()); + assert!(!result.integrity_valid, "a tampered file MUST fail"); + assert!(result.files_failed > 0); + } + + /// A structurally malformed manifest is still a hard failure — that is not + /// a disagreement about algorithms, it is a document that cannot be + /// checked at all. + #[test] + fn a_malformed_manifest_still_fails_closed() { + let dir = create_test_dir(); + let mut manifest = generate_manifest(dir.path(), "2.0.0").unwrap(); + manifest.manifest_hash = String::new(); let result = check_full(&manifest, dir.path()); assert!(!result.integrity_valid); assert_eq!(result.failure_reason, "manifest"); + assert_eq!(result.files_checked, 0); } #[test] @@ -874,7 +1005,10 @@ mod tests { fn test_verify_manifest_integrity() { let dir = create_test_dir(); let manifest = generate_manifest(dir.path(), "2.0.0").unwrap(); - assert!(verify_manifest_integrity(&manifest)); + assert_eq!( + verify_manifest_integrity(&manifest), + Some(ManifestHashCheck::VerifiedConcatenated) + ); } #[test] diff --git a/src/ciris-verify-core/src/unified.rs b/src/ciris-verify-core/src/unified.rs index d7dd64f4..9108795c 100644 --- a/src/ciris-verify-core/src/unified.rs +++ b/src/ciris-verify-core/src/unified.rs @@ -2804,6 +2804,8 @@ mod tests { #[test] fn test_file_check_summary() { let integrity = FileIntegrityResult { + manifest_hash_check: + crate::security::file_integrity::ManifestHashCheck::VerifiedConcatenated, integrity_valid: true, total_files: 100, files_checked: 100, @@ -2828,6 +2830,8 @@ mod tests { #[test] fn test_file_check_summary_partial() { let integrity = FileIntegrityResult { + manifest_hash_check: + crate::security::file_integrity::ManifestHashCheck::VerifiedConcatenated, integrity_valid: true, total_files: 100, files_checked: 50,