Skip to content
Merged
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: **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 |
|-------|--------|-------|
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`).

- **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.
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 = "14.0.0"
version = "14.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__ = "14.0.0"
__version__ = "14.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 = "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"
Expand Down
1 change: 1 addition & 0 deletions evidence/cc_impl.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/ciris-verify-core/src/bin/ciris_verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading