From ffa3b5f163eb394563d6b5749724264f10bb45c8 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 18 Aug 2026 08:10:06 -0500 Subject: [PATCH 1/2] fold: the registry slice is conferred, not configured (CIRISRegistry#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the registry fold — the role gate. Composes nothing yet, and says so. ## Why The ordering constraint on the fold is that no server converts to a canonical node until this repo can serve registry capabilities UNDER THE GRANTED ROLE. Convert first and you turn three working registries into three blessed nodes that cannot do registry work. Today the slice is selected by `cfg.slices.registry` — a boolean. An operator setting a boolean is exactly the self-assertion the accord-scrub model exists to remove: a node is canonical because the trust root signed off, and the same must be true of the authority slice it runs. ## The verbs already exist — no charter amendment This was the open question and the answer is favourable. The baked `genesis-charter` declares `[infra:attest, infra:serve, infra:store, infra:transport]`, and `genesis-grant:ciris-canonical-1-d7bdeu223k` carries all four. Registry's work is attestation-shaped, so it rides `infra:attest` + `infra:serve` — verbs a canonical node holds the moment it is blessed. That matters because the scopes live in the signed bytes, so amending the charter is an m-of-n re-scrub by the holder roster, not an edit. This change needs none. ## What lands `registry_slice_conferred()` walks `capability_roots_to_trusted_root(dir, me, me, INFRA_ATTEST_SCOPE)`. Both key ids are ours deliberately: the question is "do I hold this capability, from a root I MYSELF accept?", and the second half is the operator's un-trust lever — delete the `trust:accepts` row and the walk returns None, the slice goes dark on its own, nothing special-cased it. The check lives INSIDE compose_registry rather than at the call site, because an authority check belongs with the thing it authorises. `cfg.slices.registry` survives as an operator opt-out only: it can keep a blessed node from serving, and can never make an unblessed node serve. It cannot join `Capabilities`, which is evaluated BEFORE the Engine is open (a pre-corpus structural gate — cf. DEFAULT_LENS_STORE_MIN_GIB, a baked constant precisely because there is no corpus to read config from yet). A delegation-graph walk needs the corpus, so this gate is necessarily post-corpus. ## The trap this change is shaped around `compose_registry` was `todo!()`, unreachable only because the config bool defaults to false. Swapping the boolean for the grant check WITHOUT first giving the function a non-panicking body would panic at boot on exactly the nodes that are blessed — canonical-1 holds all four charter verbs. So the body is honest instead: conferred nodes log that they are blessed and that the surfaces are not composed yet; unblessed nodes log the withholding as the ordinary steady state it is. Ok(None) is not an error — a node that was never blessed must still finish booting and serve everything else. Three tests pin it, including that regression directly: an unblessed node resolves to Ok(None), withholding is not a boot failure, and asking about a stranger's key confers nothing. ## Not in this change The surfaces (phases 2-4): the persist-native rewrite of builds / verify / revocation / integrity / transparency, Portal's UI arriving as client cards, and the public GenesisBundle broadcast that retires /v1/steward-key. Scoped in FSD/REGISTRY_SLICE_ROLE_GATE.md §6, including why steward-key retires rather than being carried (three mutually incompatible schemas, none of which verify's actual HTTP client can parse). Upstream: CIRISRegistry#76 is DONE — registry-core now resolves on this repo's exact triple (persist v32.3.0 / edge v17.4.1 / verify v13.3.1), which is what made it composable here at all. Refs CIRISRegistry#62, CIRISRegistry#76, CIRISRegistry#133, CIRISServer#441 --- FSD/REGISTRY_SLICE_ROLE_GATE.md | 186 ++++++++++++++++++++++++++++++++ src/compose.rs | 161 ++++++++++++++++++++++++++- 2 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 FSD/REGISTRY_SLICE_ROLE_GATE.md diff --git a/FSD/REGISTRY_SLICE_ROLE_GATE.md b/FSD/REGISTRY_SLICE_ROLE_GATE.md new file mode 100644 index 00000000..bf3186bf --- /dev/null +++ b/FSD/REGISTRY_SLICE_ROLE_GATE.md @@ -0,0 +1,186 @@ +# FSD — The registry slice is conferred, not configured + +**Status:** Phase 1 IMPLEMENTED (the gate). Phases 2–4 are the surface work. +**Companion:** [`REGISTRY_FOLD_DERISK.md`](REGISTRY_FOLD_DERISK.md) (what the fold needs), +[`TRUST_ROOT_CAPABILITY_GATE.md`](TRUST_ROOT_CAPABILITY_GATE.md) (the capability model this +applies), [`MESH_SEED_RUNBOOK_POST_DELEGATION.md`](MESH_SEED_RUNBOOK_POST_DELEGATION.md) +(the ceremony that confers it). +**Upstream:** CIRISRegistry#76 (co-bump, **done** — registry-core now resolves on +persist v32.3.0 / edge v17.4.1 / verify v13.3.1, matching this repo's pins exactly), +CIRISRegistry#62 (the three-siblings umbrella), CIRISServer#441 (the admission quorum). + +--- + +## 1. The ordering constraint this exists to enforce + +> **No server converts to a canonical node until CIRISServer can serve registry +> capabilities under the granted role.** + +Convert first and you turn three working registries into three blessed nodes that +cannot do registry work. The gate is what makes "can serve registry capabilities" +a property the node *evaluates*, rather than a claim the operator makes. + +## 2. Why a boolean was the wrong shape + +Today the slice is selected by config: + +```rust +pub struct Slices { pub lens: bool, pub registry: bool, pub node: bool } +// default: registry: false +``` + +```rust +if cfg.slices.registry { + compose_registry(&edge, &engine, &cfg).await?; // todo!() +} +``` + +An operator setting a boolean is exactly the self-assertion the accord-scrub model +exists to remove. A node is canonical because **the trust root signed off**, and the +same must be true of the authority slice it runs: the accord confers `infra:attest` +and `infra:serve`, and the node serves the registry surface *because* it holds them. + +**The verbs already exist.** This was the open question and the answer is favourable: +the baked `genesis-charter` declares +`[infra:attest, infra:serve, infra:store, infra:transport]`, and +`genesis-grant:ciris-canonical-1-d7bdeu223k` carries **all four**. Registry's work — +identity, license, revocation, build provenance — is attestation-shaped, so it rides +`infra:attest` + `infra:serve`, both of which a canonical node holds the moment it is +blessed. **No new capability verb, and therefore no charter amendment** — which +matters, because the scopes live in the signed bytes, so amending the charter is an +m-of-n re-scrub by the holder roster, not an edit. + +## 3. Where the gate can and cannot live + +The lens slice is already capability-gated rather than config-gated, in the same +dispatch block: + +```rust +if caps.lens_store { LensCore::attach_handler(...).await?; } +``` + +but that is **not** the pattern to copy wholesale, and the reason is load-bearing. +`Capabilities::detect` runs **before the Engine is open** — it is a pre-corpus +structural gate (free disk), which is precisely why `DEFAULT_LENS_STORE_MIN_GIB` is a +baked constant and not a `config:*` CEG object: there is no corpus to read it from yet. + +The registry gate is the opposite kind of question. "Does this node hold `infra:attest` +from a root it accepts?" is a **delegation-graph walk over the federation directory** — +it *requires* the corpus. So it cannot join `Capabilities`, and must be evaluated at +slice-composition time, after the Engine exists. It is a **post-corpus** gate. + +## 4. The check + +persist supplies the walk: + +```rust +capability_roots_to_trusted_root( + directory, + user_key_id, // who accepts the root — this node + subject_key_id, // who holds the capability — this node + scope, // "infra:attest" +) -> Result, Error> +``` + +Both ids are this node's own `key_id`: we are asking *"do I hold this capability, from +a root I myself accept?"* Both halves matter. The `trust:accepts` edge is the operator's +un-trust lever — delete that one row and the walk returns `None`, the slice goes dark on +its own, and nothing special-cased it. + +`None` is not an error. A node that has never been blessed is in a legitimate steady +state; it simply does not serve the authority slice. + +## 5. Fail-secure composition, and the boot-panic trap + +There is a trap here that must be named, because the obvious implementation is a +production outage. + +`compose_registry()` is currently `todo!()`. It is unreachable today only because +`slices.registry` defaults to `false`. **Naively swapping the boolean for the grant +check would make canonical-1 — which holds all four verbs — evaluate the gate to +`true` and panic at boot.** The gate cannot land before the slice it gates has a +non-panicking body. + +So the increment is ordered: + +1. **The grant is the authority, and it lives inside the slice.** `compose_registry` + performs its own gate check and refuses when the grant is absent. Authority checks + belong with the thing they authorise, not at the call site. +2. **Config may only decline, never confer.** `slices.registry` is retained as an + operator *opt-out*; it can keep a blessed node from serving, and can never make an + unblessed node serve. Default stays `false`, so no deployed node changes behaviour. +3. **The body is honest about what it does not do yet.** Until the surfaces land, a + conferred node logs that it is blessed and that the slice is not yet composed. It + does not panic, and it does not silently pretend to serve. + +``` +grant absent → refuse, log the reason, slice off (fail-secure) +grant present → proceed (Phase 1: log-only; Phases 2-4: compose the surfaces) +config off → decline before either (operator opt-out) +``` + +## 6. What the slice will actually compose (Phases 2–4) + +Not a port. `ciris-registry-core` is 21,689 lines of which **12,153 touch `sqlx`** and +**14,400 touch `tonic`**; only 3,604 touch neither. That mass does not fold — it +dissolves. Registry's tables become the shared corpus and the gRPC surface stops +existing rather than being re-hosted in axum. + +The impedance is *not* Postgres-versus-SQLite — this repo runs full Postgres via +persist's `postgres` feature on the Linux target. It is **raw sqlx versus the persist +`Engine`**: hand-written SQL bypasses the signing, scrub and quorum-merge machinery +that makes a row federate. That is what the rewrite buys, and why the tables cannot +come along unchanged. + +What survives is the residual with live consumers — surfaces this repo does not have: + +| Surface | Consumer | Phase | +|---|---|---| +| `/v1/builds`, `/v1/builds/{version}`, `/hash/{h}` | CIRISVerify | 2 | +| `/v1/verify/{binary,build,function}-manifest*` | CIRISVerify | 2 | +| `/v1/verify/key/{fingerprint}` | CIRISVerify | 2 | +| `/v1/revocation/{target_id}` | CIRISVerify | 2 | +| `/v1/transparency/sth/cosign`, `/witnesses` | transparency log | 3 | +| `/v1/integrity/*` (1,480 LOC — Play Integrity + iOS App Attest) | mobile attestation | 3 | +| Portal's organizations / users / key custody | **the KMP client, as cards** | 4 | +| `/v1/steward-key` | — | **retires** | + +Portal's gRPC service is not rebuilt as an API. Its UI comes in as cards alongside the +existing `AccordScreen`, `IdentityManagementScreen`, `DelegationsScreen`, +`BillingScreen` and `AuditScreen` — which is what lets the RPC layer go away instead of +being re-hosted. + +### `/v1/steward-key` retires rather than being carried + +Worth recording why, because it looks like a surface with consumers. It is not: three +mutually incompatible schemas exist and **no two agree**. Registry serves +`{stewards[], verification_policy, …}`; verify's actual HTTP client +(`ciris-verify-core/src/https.rs`) expects single-steward `{classical{}, pqc{}, …}` +whose non-`Option` fields are absent from that response, so it fails to deserialize +outright; and verify's spec-conformant parser (`steward_key.rs`) expects +`{stewards[], threshold_policy, response_signature}` and is never reached by the HTTP +path. The live response also declares `signature_mode: "HYBRID_REQUIRED"` while +carrying no signature field at all, and asserts `hardware_class: HSM_PROD` under +`self_attested: true`. + +There is no working contract to preserve. The replacement is the **public broadcast of +the persist-baked `GenesisBundle`**, which is self-authenticating — it carries its own +hybrid `authorizations` from two accord holders over the charter — and therefore +satisfies CIRISRegistry#133 by construction rather than by patch. Note this is genuinely +net-new: `GET /v1/trust-root` today is loopback-gated, an operator surface, not a +federation broadcast. + +## 7. Only then, the conversion + +With the slice served under a conferred role, registry-us and registry-eu convert via +the existing ceremony — `add-canonical` from the Trust Root card, A1's YubiKey plus the +USB-wrapped ML-DSA cosign, persist refusing the `canonical` role on any record that is +not anchor-scrubbed (`CanonicalRoleNotAccordConferred`). Identities carry byte-identically +per `REGISTRY_FOLD_DERISK.md` §2 — no re-key, same addresses. Then `canonical_seed.json` +is re-baked with three `serve_nodes` and tagged, so the portable root carries all three. + +**Decide the admission quorum first (CIRISServer#441).** `add-canonical` is classed +`Operational` and resolves to 1-of-3 today, while the baked founding record is 2-of-3 +(A1 + B1) because CIRISPersist#390 judged a single-anchor founding record a first-strike +weakness. These two admissions double the canonical set; they should not inherit 1-of-3 +by default. diff --git a/src/compose.rs b/src/compose.rs index 4c6becb6..35f4b769 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -552,6 +552,10 @@ pub async fn serve_with_adapter(cfg: ServerConfig, adapter: Arc) -> (no local corpus / read API); free up disk to the baked minimum" ); } + // `slices.registry` is an operator OPT-OUT, not the authorization. The grant + // is checked inside compose_registry, which withholds the slice when this + // node holds no accord-conferred infra:attest. Config can decline; it can + // never confer. (FSD/REGISTRY_SLICE_ROLE_GATE.md) if cfg.slices.registry { compose_registry(&edge, &engine, &cfg).await?; } @@ -3717,11 +3721,83 @@ pub async fn run_config_get( crate::graph_config::get_config(&engine, key).await } -/// Authority slice — folds in at **Server 0.6** (CIRISRegistry#76). Attaches to -/// the shared Edge (the node's single identity) + serves the registry trust -/// surface over the shared Engine. SCAFFOLD. (0.5 is config-as-CEG; registry is 0.6.) -async fn compose_registry(_edge: &Edge, _engine: &Arc, _cfg: &ServerConfig) -> Result<()> { - todo!("registry slice (Server 0.6) — pin ciris-registry-core (CIRISRegistry#76) + attach to the shared Edge") +/// Is THIS node conferred the authority slice? — the registry role gate. +/// +/// See `FSD/REGISTRY_SLICE_ROLE_GATE.md`. A node serves the registry surface +/// because the accord blessed it to, never because an operator set a boolean: +/// the trust root confers `infra:attest` (and `infra:serve`), and holding that +/// grant IS the authorization. That is the same rule `add-canonical` already +/// enforces on the way in — persist refuses the `canonical` role on any record +/// that is not anchor-scrubbed — applied to what the blessed node then does. +/// +/// Both key ids are ours on purpose. The question is *"do I hold this +/// capability, from a root I MYSELF accept?"*, and the second half is the +/// operator's un-trust lever: delete the `trust:accepts` row and this walk +/// returns `None`, the slice goes dark on its own, and nothing special-cased +/// it (`FSD/TRUST_ROOT_CAPABILITY_GATE.md` §1). +/// +/// `Ok(None)` is NOT an error. A node that was never blessed is in a legitimate +/// steady state; it simply does not serve the authority slice. Only a failure +/// to *evaluate* the walk is an error, and it is fail-secure at the call site. +/// +/// This cannot join [`Capabilities`], which is evaluated BEFORE the Engine is +/// open (a pre-corpus structural gate — see `DEFAULT_LENS_STORE_MIN_GIB`). +/// A delegation-graph walk needs the corpus, so the registry gate is +/// necessarily post-corpus and lives here, at slice-composition time. +pub(crate) async fn registry_slice_conferred( + engine: &Arc, + node_key_id: &str, +) -> Result> { + let directory = engine.federation_directory(); + ciris_persist::federation::trust_root::capability_roots_to_trusted_root( + directory.as_ref(), + node_key_id, // who accepts the root — us + node_key_id, // who holds the capability — us + ciris_persist::federation::trust_root::INFRA_ATTEST_SCOPE, + ) + .await + .map_err(|e| anyhow::anyhow!("evaluate the registry-slice capability walk: {e}")) +} + +/// Authority slice — folds in at **Server 0.6** (CIRISRegistry#76, co-bump DONE: +/// registry-core now resolves on this repo's exact triple). Attaches to the +/// shared Edge (the node's single identity) + serves the registry trust surface +/// over the shared Engine. +/// +/// **The grant is the authority, and the check lives HERE rather than at the +/// call site**, because an authority check belongs with the thing it authorises. +/// `cfg.slices.registry` is retained as an operator *opt-out* only: it can keep +/// a blessed node from serving, and can never make an unblessed node serve. +/// +/// Phase 1 (this change) wires the gate and composes nothing — deliberately. +/// `compose_registry` was a `todo!()`, unreachable only because the config bool +/// defaults to false; making the grant the trigger without first giving this +/// function a non-panicking body would panic at boot on exactly the nodes that +/// ARE blessed (canonical-1 holds all four charter verbs). The surfaces land in +/// phases 2-4 inside the conferred branch. +async fn compose_registry(_edge: &Edge, engine: &Arc, cfg: &ServerConfig) -> Result<()> { + let Some(grant) = registry_slice_conferred(engine, &cfg.key_id).await? else { + tracing::info!( + node = %cfg.key_id, + scope = ciris_persist::federation::trust_root::INFRA_ATTEST_SCOPE, + "registry slice WITHHELD — this node holds no accord-conferred infra:attest \ + grant from a trust root it accepts. This is a normal steady state for an \ + unblessed node; the slice is conferred by `add-canonical`, never configured \ + (FSD/REGISTRY_SLICE_ROLE_GATE.md)" + ); + return Ok(()); + }; + + tracing::info!( + node = %cfg.key_id, + root = %grant.root_key_id, + scope = ciris_persist::federation::trust_root::INFRA_ATTEST_SCOPE, + "registry slice CONFERRED by the trust root — the authority surface is not yet \ + composed (Server 0.6 phases 2-4: the persist-native rewrite of builds / verify / \ + revocation / integrity / transparency). Serving nothing yet, and saying so rather \ + than pretending" + ); + Ok(()) } /// Consensus slice — folds in at **Server 1.0** (CIRISNodeCore#38). `install(&edge)` @@ -3730,6 +3806,81 @@ async fn compose_node(_edge: &Edge, _engine: &Arc, _cfg: &ServerConfig) todo!("node slice (Server 1.0) — pin ciris-node-core (CIRISNodeCore#38) + install(&edge)") } +#[cfg(test)] +mod registry_slice_gate_tests { + //! The registry slice is conferred, not configured + //! (`FSD/REGISTRY_SLICE_ROLE_GATE.md`). + //! + //! The regression these pin is a boot outage, not a feature. `compose_registry` + //! was a `todo!()`, reachable only because `slices.registry` defaults to false. + //! Anything that makes the grant the trigger without giving the function a + //! non-panicking body panics at boot on exactly the nodes that ARE blessed. + + use super::*; + + async fn engine_with_no_conferral() -> Arc { + use ciris_keyring::MlDsa65SoftwareSigner; + use ciris_persist::prelude::LocalSigner; + let pqc = Arc::new( + MlDsa65SoftwareSigner::from_seed_bytes(&[0xB2; 32], "ciris-server-pqc".to_string()) + .expect("pqc seed"), + ); + let signer = Arc::new(LocalSigner::from_parts( + ed25519_dalek::SigningKey::from_bytes(&[0xB1; 32]), + "unblessed-node".to_string(), + Some(pqc), + Some("ciris-server-pqc".to_string()), + )); + Arc::new( + Engine::with_signer(signer, "sqlite::memory:") + .await + .expect("engine"), + ) + } + + /// An unblessed node resolves the gate to `None` — and that is a steady + /// state, not a fault. Holding no accord conferral is the ordinary + /// condition of every node that has not been through `add-canonical`. + #[tokio::test] + async fn an_unblessed_node_is_not_conferred_the_authority_slice() { + let engine = engine_with_no_conferral().await; + let conferred = registry_slice_conferred(&engine, "unblessed-node") + .await + .expect("the walk must EVALUATE cleanly even when it confers nothing"); + assert!( + conferred.is_none(), + "a node with no accord-conferred infra:attest must not be granted the \ + authority slice — config cannot confer it and neither can absence of proof" + ); + } + + /// The boot-panic regression, pinned directly: withholding must RETURN, not + /// panic and not error. A node that is simply unblessed has to finish + /// composing and come up serving everything else. + #[tokio::test] + async fn withholding_the_slice_is_not_a_boot_failure() { + let engine = engine_with_no_conferral().await; + let conferred = registry_slice_conferred(&engine, "unblessed-node").await; + assert!( + matches!(conferred, Ok(None)), + "an unblessed node must resolve to Ok(None): Err would fail the boot of a \ + node whose only \"problem\" is that it was never blessed" + ); + } + + /// The gate asks about THIS node on both sides of the walk — "do I hold this + /// capability, from a root I myself accept?". Passing a stranger's key id + /// must not confer anything either; nothing about being asked about grants. + #[tokio::test] + async fn asking_about_a_stranger_confers_nothing() { + let engine = engine_with_no_conferral().await; + let conferred = registry_slice_conferred(&engine, "some-other-node") + .await + .expect("walk evaluates"); + assert!(conferred.is_none(), "no conferral exists for any key here"); + } +} + #[cfg(test)] mod bootstrap_hint_tests { use super::ip_addrs_from_hints; From b5beb15b4b94589add949d98a13a6361a9427e42 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 18 Aug 2026 19:02:32 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fold:=20broadcast=20the=20portable=20trust?= =?UTF-8?q?=20root=20=E2=80=94=20GET=20/v1/trust-root/bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a. The federation-facing read of the GenesisBundle persist bakes: the humanity-accord charter, its A1/B1/C1 holder roster, the infra:* scopes that charter confers, and the serve-node grants issued under it. ## What this retires CIRISRegistry published its own key as its own trust root at GET /v1/steward-key. That endpoint is retired rather than repaired (CIRISRegistry#133), and the reasons shape this module: - it carried NO signature at all while declaring `signature_mode: "HYBRID_REQUIRED"` — trust-root material arriving unauthenticated on the wire; - it asserted `hardware_class: HSM_PROD` under `self_attested: true`, a producer claim rather than evidence (CC 4.2.2.1); - and three mutually incompatible schemas for it exist across the fleet, no two of which agree. Verify's actual HTTP client expects single-steward `{classical{}, pqc{}, …}` whose non-Option fields are absent from what registry serves, so it fails to deserialize outright. Nothing downstream was successfully consuming it. The replacement is not "the same thing, signed" — it is a different SHAPE of claim. A node no longer publishes a root it asserts; it serves the root it was conferred by, and that artifact carries its own proof. ## The invariant: authority lives inside `bundle`, nowhere else The bundle is self-authenticating. Its `authorizations` are hybrid Ed25519 + ML-DSA-65 signatures from accord holders over the charter, and verify_bundle_quorum re-derives authority from the READER's own records rather than from anything the bundle says about itself — a forged bundle carrying attacker "holders" proves nothing (CIRISPersist#377). Everything outside `bundle` — bundle_fingerprint, charter_root_key_id, served_by — is unsigned convenience metadata: this node's unverified claim about itself and about bytes it is relaying. So there is deliberately NO response_signature. Signing the wrapper would prove only that the relaying node said it, which is exactly what /v1/steward-key proved and exactly what was worthless — and it would invite consumers to check the envelope instead of the artifact. The field is named `served_by` rather than anything that reads like an attestation, for the same reason. A test pins this: response_signature / signature_mode / hardware_class must never appear on the outer envelope. ## Public by design Unlike trust_root_api's import/list/delete verbs — loopback-gated, because choosing a node's trust root is the operator's own act — this is a federation read and is mounted WITHOUT a loopback layer. A peer bootstrapping into the mesh has to be able to fetch the root and check it against its own roster. Everything in the bundle is public material: public keys, signatures, an already-announced transport hint, and YubiKey PIV attestation certificates. Scanned before exposing it; no private material, no seeds, no key material. ## Tests Three, on the properties rather than the status code: the broadcast serves the baked bundle through the same accessor everything else uses (not a second path that could drift); the charter still confers infra:attest + infra:serve, pinned HERE as well as at the consumer, because if it ever stops the registry slice's role gate silently becomes unsatisfiable; and the outer envelope claims no authority. 382 lib tests green. fmt + clippy -D warnings clean. Refs CIRISRegistry#133, CIRISRegistry#62, CIRISServer#442 --- src/compose.rs | 14 ++ src/lib.rs | 1 + src/trust_root_broadcast.rs | 267 ++++++++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 src/trust_root_broadcast.rs diff --git a/src/compose.rs b/src/compose.rs index 35f4b769..94875c52 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -918,6 +918,20 @@ pub async fn serve_with_adapter(cfg: ServerConfig, adapter: Arc) -> Arc::clone(&engine), node_code.key_id.clone(), )) + // TRUST ROOT, the federation-facing read: GET + // /v1/trust-root/bundle. Deliberately NOT loopback-gated — + // unlike the import/list/delete verbs above, which are the + // operator's own act, this is how a peer bootstrapping into + // the mesh fetches the portable root and checks it against + // its own roster. The bundle is self-authenticating and the + // outer envelope claims no authority; see the module docs + // for why signing the wrapper would be worthless + // (CIRISRegistry#133 — this is what retires + // /v1/steward-key). + .merge(crate::trust_root_broadcast::router( + Arc::clone(&engine), + node_code.key_id.clone(), + )) // claim REMOTE ownership (substrate-native, node-to-node): // POST /v1/setup/claim-remote — the LOCAL node decodes the // target NodeCode, builds + hybrid-signs the owner-binding diff --git a/src/lib.rs b/src/lib.rs index 8657dfdf..e4b38903 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -387,6 +387,7 @@ mod test_bless; /// log volume nobody read. pub mod trace_plane_watch; pub mod trust_root_api; +pub mod trust_root_broadcast; /// The wire vocabularies, served to the operator UI so no picker ever /// hardcodes a member (CIRISPersist#625). pub mod vocabulary_surface; diff --git a/src/trust_root_broadcast.rs b/src/trust_root_broadcast.rs new file mode 100644 index 00000000..58dc3516 --- /dev/null +++ b/src/trust_root_broadcast.rs @@ -0,0 +1,267 @@ +//! **Broadcasting the portable trust root** (`GET /v1/trust-root/bundle`). +//! +//! The federation-facing read of the `GenesisBundle` persist bakes: the +//! `humanity-accord` charter, its A1/B1/C1 holder roster, the `infra:*` scopes +//! that charter confers, and the serve-node grants issued under it. +//! +//! # Why this exists, and what it replaces +//! +//! CIRISRegistry published its own key as its own trust root at +//! `GET /v1/steward-key`. That endpoint is being retired rather than repaired +//! (CIRISRegistry#133), and the reasoning is worth carrying here because it is +//! the whole design constraint on this module: +//! +//! - the response carried **no signature at all**, while declaring +//! `signature_mode: "HYBRID_REQUIRED"` — trust-root material arriving +//! unauthenticated on the wire; +//! - it asserted `hardware_class: HSM_PROD` under `self_attested: true`, which +//! is a producer claim, not evidence (CC 4.2.2.1); +//! - and three mutually incompatible schemas for it exist across the fleet, no +//! two of which agree, so nothing was successfully consuming it anyway. +//! +//! The replacement is not "the same thing, signed". It is a different *shape* of +//! claim. A node no longer publishes a root it asserts; it serves the root it was +//! **conferred by**, and that artifact carries its own proof. +//! +//! # The authority is inside `bundle`, and nowhere else +//! +//! This is the property that must survive every future edit to this file. +//! +//! The bundle is **self-authenticating**: its `authorizations` are hybrid +//! Ed25519 + ML-DSA-65 signatures from accord holders over the charter, and +//! `verify_bundle_quorum` re-derives authority from the reader's OWN records +//! rather than from anything the bundle says about itself. A forged bundle +//! carrying attacker "holders" proves nothing (the CIRISPersist#377 lesson). +//! +//! Everything OUTSIDE `bundle` in this response — `bundle_fingerprint`, +//! `charter_root_key_id`, `served_by` — is **unsigned convenience metadata**. It +//! is this node's unverified claim about itself and about bytes it is relaying. +//! A consumer MUST verify the bundle and MUST NOT promote any outer field to a +//! trust decision. Signing the envelope would not help: it would only prove that +//! the relaying node said it, which is precisely the thing `/v1/steward-key` +//! proved and precisely the thing that was worthless. +//! +//! So: no `response_signature` here, deliberately, and the field names say +//! `served_by` rather than anything that reads like an attestation. +//! +//! # Public by design +//! +//! Unlike [`crate::trust_root_api`] — whose import/list/delete verbs are +//! loopback-gated, because choosing a node's trust root is the operator's own +//! act — this is a **federation read**. The bundle is entirely public material: +//! public keys, signatures, an already-announced transport hint, and YubiKey PIV +//! attestation certificates. There is nothing here to withhold, and withholding +//! it would defeat the point: a peer bootstrapping into the mesh needs to be able +//! to fetch the root and check it against its own roster. + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router}; +use ciris_persist::prelude::Engine; +use serde::Serialize; + +#[derive(Clone)] +pub struct BroadcastState { + pub engine: Arc, + /// THIS node's federation key — the `user_key_id` side of the trust edge, + /// and therefore the identity whose acceptance is being reported. + pub node_key_id: String, +} + +/// What this node says about itself while relaying the bundle. **Unsigned.** +/// +/// Present so a consumer can tell a node that merely *knows* the root from one +/// that has actually accepted it — useful for diagnostics, never for trust. +#[derive(Debug, Serialize)] +struct ServedBy { + node_key_id: String, + /// Does this node's own `trust:accepts` edge reach the charter root? + /// + /// `false` is a legitimate state, not an error: a node can hold and relay + /// the bundle without having accepted it. It is also the operator's un-trust + /// lever — deleting that one row flips this and fails the node's own gates + /// closed, without touching the bundle it serves. + accepts_this_root: bool, +} + +#[derive(Debug, Serialize)] +struct BundleBroadcast { + /// The artifact. **The only part of this response that carries authority.** + bundle: serde_json::Value, + /// Content fingerprint of `bundle`, so a caller can cheaply notice a change + /// without diffing. Convenience only — recompute it yourself if it matters. + bundle_fingerprint: Option, + /// The charter root the bundle declares (`humanity-accord`). Read off the + /// bundle for discoverability; verify it, do not trust it. + charter_root_key_id: Option, + served_by: ServedBy, +} + +fn err(code: StatusCode, reason: &str, msg: impl Into) -> Response { + ( + code, + Json(serde_json::json!({ "error": msg.into(), "reason_id": reason })), + ) + .into_response() +} + +/// `GET /v1/trust-root/bundle` — serve the portable trust root. +async fn get_bundle(State(st): State) -> Response { + let bundle = ciris_persist::federation::genesis::canonical_genesis_bundle(); + + let bundle_json = match serde_json::to_value(bundle) { + Ok(v) => v, + Err(e) => { + return err( + StatusCode::INTERNAL_SERVER_ERROR, + "bundle_not_serializable", + format!("the baked genesis bundle could not be serialized: {e}"), + ) + } + }; + + // Both of these are best-effort. A bundle we cannot fingerprint or whose + // charter we cannot name is still worth serving — the consumer verifies the + // bundle itself, and withholding the artifact because a convenience field is + // unavailable would trade a real capability for a cosmetic one. + let bundle_fingerprint = crate::mesh_genesis::fingerprint(bundle).ok(); + let charter_root_key_id = crate::mesh_genesis::charter_root_key_id(bundle); + + let accepts_this_root = match &charter_root_key_id { + Some(root) => ciris_persist::federation::trust_root::trust_root_valid( + st.engine.federation_directory().as_ref(), + &st.node_key_id, + root, + ) + .await + .ok() + .and_then(|v| serde_json::to_value(&v).ok()) + .and_then(|j| j.get("user_accepts").and_then(serde_json::Value::as_bool)) + .unwrap_or(false), + None => false, + }; + + Json(BundleBroadcast { + bundle: bundle_json, + bundle_fingerprint, + charter_root_key_id, + served_by: ServedBy { + node_key_id: st.node_key_id.clone(), + accepts_this_root, + }, + }) + .into_response() +} + +/// The broadcast router. **Merge WITHOUT a loopback layer** — see the module +/// docs: this is a federation read, and a peer bootstrapping into the mesh has +/// to be able to reach it. +pub fn router(engine: Arc, node_key_id: String) -> Router { + Router::new() + .route("/v1/trust-root/bundle", axum::routing::get(get_bundle)) + .with_state(BroadcastState { + engine, + node_key_id, + }) +} + +#[cfg(test)] +mod tests { + //! The invariant under test is not "the handler returns 200". It is that the + //! response carries the artifact and claims no authority of its own — the + //! failure mode `/v1/steward-key` shipped for years. + + use super::*; + + fn baked() -> &'static crate::mesh_genesis::GenesisBundle { + ciris_persist::federation::genesis::canonical_genesis_bundle() + } + + /// The bundle we broadcast is the one persist bakes, reached through the + /// same accessor everything else uses — not a second path to the same bytes + /// that could drift from it. + #[test] + fn the_broadcast_serves_the_baked_bundle_verbatim() { + let b = baked(); + let json = serde_json::to_value(b).expect("bundle serializes"); + assert!( + json.get("authorizations").is_some(), + "the authorizations are what make this artifact self-authenticating — \ + a bundle serialized without them would be exactly the unsigned \ + trust-root material /v1/steward-key shipped" + ); + assert!( + json.get("holders").is_some() && json.get("serve_nodes").is_some(), + "a consumer needs the holder roster to verify the quorum and the serve \ + nodes to know who was blessed under it" + ); + } + + /// The charter names the capability ceiling of the whole trust domain. If + /// this ever stops carrying the four infra verbs, the registry slice's role + /// gate silently stops being satisfiable — so pin it here, where the bundle + /// is served, and not only where it is consumed. + #[test] + fn the_charter_confers_the_infra_verbs_the_registry_slice_needs() { + let json = serde_json::to_value(baked()).expect("bundle serializes"); + let charter = json["attestations"] + .as_array() + .expect("attestations is an array") + .iter() + .find(|a| a["attestation"]["attestation_id"] == "genesis-charter") + .expect("the bundle carries a genesis-charter"); + let scope = charter["attestation"]["attestation_envelope"]["scope"] + .as_array() + .expect("the charter declares a scope array"); + let scopes: Vec<&str> = scope.iter().filter_map(|s| s.as_str()).collect(); + for needed in [ + ciris_persist::federation::trust_root::INFRA_ATTEST_SCOPE, + ciris_persist::federation::trust_root::INFRA_SERVE_SCOPE, + ] { + assert!( + scopes.contains(&needed), + "the charter must confer {needed} — the registry slice's role gate \ + walks for it (FSD/REGISTRY_SLICE_ROLE_GATE.md). Charter carries: {scopes:?}" + ); + } + } + + /// The outer envelope must stay authority-free. A `response_signature` here + /// would only prove the relaying node said it — the exact worthless claim + /// /v1/steward-key made — and would invite consumers to check the wrapper + /// instead of the bundle. + #[test] + fn the_outer_envelope_claims_no_authority() { + let body = BundleBroadcast { + bundle: serde_json::json!({"stub": true}), + bundle_fingerprint: Some("f".into()), + charter_root_key_id: Some("humanity-accord".into()), + served_by: ServedBy { + node_key_id: "some-node".into(), + accepts_this_root: true, + }, + }; + let json = serde_json::to_value(&body).expect("serializes"); + let outer: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + for forbidden in ["response_signature", "signature_mode", "hardware_class"] { + assert!( + !outer.contains(&forbidden), + "`{forbidden}` must not appear on the outer envelope: the authority \ + lives inside `bundle` and signing the wrapper would prove only that \ + the relay said so (CIRISRegistry#133)" + ); + } + assert!( + outer.contains(&"bundle"), + "the artifact itself must be present — it is the only part that matters" + ); + } +}