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..94875c52 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?; } @@ -914,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 @@ -3717,11 +3735,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 +3820,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; 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" + ); + } +}