1	//! Release metadata + signature verification (C1) — the gate that decides which
2	//! binary is even *eligible* to be handed off (ADR-0004 §D).
3	//!
4	//! ADR-0004 makes signing **mandatory**: peer-propagation (M4) otherwise lets one
5	//! compromised node poison the subnet, so every binary is signature-verified
6	//! before handoff regardless of source ([`REQ-UPD-2`]). Signature alone is
7	//! insufficient (codex SERIOUS #5), so v1 also enforces the
8	//! [`REQ-HAZARD-UPDATE-ROLLBACK`] hardening set:
9	//!
10	//! - **monotonic version** — a release whose version is not strictly greater than
11	//!   the running build is rejected (downgrade attack / accidental rollback);
12	//! - **metadata expiry** — signed metadata past its `expires_at_ms` is rejected
13	//!   (a captured old-but-valid release cannot be replayed forever);
14	//! - **channel pinning** — a release for a channel other than the pinned one is
15	//!   rejected (a `beta` build cannot be slipped onto a `stable` node);
16	//! - **key rotation + revocation** — the signing key is selected by `key_id` from
17	//!   a trusted set (rotation = add a key), and a revoked `key_id` is rejected even
18	//!   with an otherwise-valid signature.
19	//!
20	//! The signature covers the **metadata**, and the metadata binds the binary by
21	//! its SHA-256 ([`ReleaseMetadata::artifact_sha256`]) — so [`verify_artifact`]
22	//! confirms the actual bytes match what was signed. The two together mean a valid
23	//! signature attests to *these exact binary bytes*, not just a manifest.
24	//!
25	//! Verification is pure (no I/O, no clock): the caller passes `now_ms` and the
26	//! artifact bytes, so the policy is fully testable and the same code runs in the
27	//! daemon and in tests. The C0 [`crate::update`] engine consumes the verified
28	//! release; this module never triggers a handoff itself.
29	// [impl->REQ-UPD-2]
30
31	use std::collections::{BTreeMap, BTreeSet};
32
33	use ed25519_dalek::{Signature, Verifier, VerifyingKey};
34	use serde::{Deserialize, Serialize};
35	use sha2::{Digest, Sha256};
36
37	use crate::update::ReleaseSpec;
38
39	/// The signed description of a release. Serialized to the exact bytes that get
40	/// signed; the signature in [`SignedRelease`] is computed over `metadata_json`'s
41	/// UTF-8 bytes verbatim, so verification never re-serializes (no canonicalization
42	/// dependency — we verify the bytes we were given).
43	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44	pub struct ReleaseMetadata {
45	    /// Monotonic release version. An update is accepted only if this is strictly
46	    /// greater than the running build's version (rollback rejection).
47	    pub version: u64,
48	    /// The release channel (e.g. `stable`, `beta`). Must equal the node's pin.
49	    pub channel: String,
50	    /// Wall-clock expiry of this signed metadata (epoch ms). Past it ⇒ rejected.
51	    pub expires_at_ms: u64,
52	    /// Which release key signed this — selects the verifying key + drives
53	    /// rotation/revocation.
54	    pub key_id: String,
55	    /// Lowercase-hex SHA-256 of the release binary the signature attests to.
56	    pub artifact_sha256: String,
57	    /// The IPC protocol version the release's **brain** speaks (ADR-0004 §A axis,
58	    /// fed to [`crate::update::classify`] once the release is verified).
59	    pub brain_ipc_version: u32,
60	    /// The held-resource ABI the release's **broker** implements (ADR-0004 §A axis).
61	    pub broker_resource_abi: u32,
62	}
63
64	impl ReleaseMetadata {
65	    /// The update-class inputs this verified release carries (ADR-0004 §A).
66	    pub fn spec(&self) -> ReleaseSpec {
67	        ReleaseSpec {
68	            brain_ipc_version: self.brain_ipc_version,
69	            broker_resource_abi: self.broker_resource_abi,
70	        }
71	    }
72	}
73
74	/// A release's signed metadata: the exact signed bytes plus the detached Ed25519
75	/// signature (lowercase hex, 64 bytes / 128 hex chars). Transport/on-disk shape.
76	#[derive(Debug, Clone, Serialize, Deserialize)]
77	pub struct SignedRelease {
78	    /// The exact UTF-8 bytes that were signed (a serialized [`ReleaseMetadata`]).
79	    pub metadata_json: String,
80	    /// The Ed25519 signature over `metadata_json`'s bytes, lowercase hex.
81	    pub signature_hex: String,
82	}
83
84	/// The artifact entry inside a platform-targeted update set. The map key is
85	/// the Rust target triple (e.g. `x86_64-pc-windows-msvc`); this entry carries
86	/// the digest and update-class axes for that platform's binary.
87	// [impl->REQ-UPD-6]
88	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89	pub struct UpdateArtifactMetadata {
90	    /// Lowercase-hex SHA-256 of this platform's binary.
91	    pub artifact_sha256: String,
92	    /// The IPC protocol version this platform's **brain** speaks.
93	    pub brain_ipc_version: u32,
94	    /// The held-resource ABI this platform's **broker** implements.
95	    pub broker_resource_abi: u32,
96	    /// Optional human/package-facing asset name; not used for trust.
97	    #[serde(default, skip_serializing_if = "Option::is_none")]
98	    pub asset_name: Option<String>,
99	}
100
101	impl UpdateArtifactMetadata {
102	    /// The update-class inputs for this platform artifact.
103	    pub fn spec(&self) -> ReleaseSpec {
104	        ReleaseSpec {
105	            brain_ipc_version: self.brain_ipc_version,
106	            broker_resource_abi: self.broker_resource_abi,
107	        }
108	    }
109	}
110
111	/// Signed diagnostic provenance for debug rollout observability. None of these
112	/// fields affect trust; they answer "which bits are running where?"
113	// [impl->REQ-UPD-6]
114	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
115	pub struct UpdateSetProvenance {
116	    #[serde(default, skip_serializing_if = "Option::is_none")]
117	    pub git_commit: Option<String>,
118	    #[serde(default, skip_serializing_if = "Option::is_none")]
119	    pub git_dirty: Option<bool>,
120	    #[serde(default, skip_serializing_if = "Option::is_none")]
121	    pub build_host: Option<String>,
122	    #[serde(default, skip_serializing_if = "Option::is_none")]
123	    pub build_timestamp_ms: Option<u64>,
124	}
125
126	/// A signed update set's metadata: one release version/channel/key and a set of
127	/// per-platform artifacts keyed by Rust target triple.
128	// [impl->REQ-UPD-6]
129	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130	pub struct UpdateSetMetadata {
131	    /// Monotonic release version within `channel`.
132	    pub version: u64,
133	    /// The release channel. A node accepts only its pinned channel.
134	    pub channel: String,
135	    /// Wall-clock expiry of this signed metadata (epoch ms).
136	    pub expires_at_ms: u64,
137	    /// Which release key signed this set.
138	    pub key_id: String,
139	    /// Platform artifacts keyed by Rust target triple.
140	    pub artifacts: BTreeMap<String, UpdateArtifactMetadata>,
141	    /// Optional signed diagnostics for debugging and fleet inspection.
142	    #[serde(default, skip_serializing_if = "Option::is_none")]
143	    pub provenance: Option<UpdateSetProvenance>,
144	}
145
146	impl UpdateSetMetadata {
147	    /// The artifact entry for `platform`, or the typed rejection that powers
148	    /// debug-rollout convergence tables.
149	    // [impl->REQ-UPD-6]
150	    pub fn artifact_for(&self, platform: &str) -> Result<&UpdateArtifactMetadata, RejectReason> {
151	        self.artifacts
152	            .get(platform)
153	            .ok_or_else(|| RejectReason::NoArtifactForPlatform {
154	                platform: platform.to_string(),
155	            })
156	    }
157	}
158
159	/// A signed update set: exact signed metadata bytes plus detached Ed25519
160	/// signature. The signature covers all platform artifact digests.
161	// [impl->REQ-UPD-6]
162	#[derive(Debug, Clone, Serialize, Deserialize)]
163	pub struct SignedUpdateSet {
164	    /// The exact UTF-8 bytes that were signed (serialized [`UpdateSetMetadata`]).
165	    pub metadata_json: String,
166	    /// The Ed25519 signature over `metadata_json`'s bytes, lowercase hex.
167	    pub signature_hex: String,
168	}
169
170	/// Canonical platform identifier for this build, matching Rust target triples
171	/// used by Cargo and ADR-0016.
172	// [impl->REQ-UPD-6]
173	pub fn current_platform() -> &'static str {
174	    #[cfg(all(target_arch = "x86_64", target_os = "windows", target_env = "msvc"))]
175	    {
176	        "x86_64-pc-windows-msvc"
177	    }
178	    #[cfg(all(target_arch = "x86_64", target_os = "linux", target_env = "gnu"))]
179	    {
180	        "x86_64-unknown-linux-gnu"
181	    }
182	    #[cfg(not(any(
183	        all(target_arch = "x86_64", target_os = "windows", target_env = "msvc"),
184	        all(target_arch = "x86_64", target_os = "linux", target_env = "gnu")
185	    )))]
186	    {
187	        "unknown"
188	    }
189	}
190
191	/// The trust policy a node verifies a release against. Pure data — no clock, no
192	/// I/O — so verification is deterministic and testable.
193	#[derive(Debug, Clone)]
194	pub struct VerifyPolicy {
195	    /// Trusted release keys by `key_id`. Rotation = add a new id; old ids may
196	    /// linger until retired.
197	    pub trusted_keys: BTreeMap<String, VerifyingKey>,
198	    /// Explicitly revoked `key_id`s — rejected even with a valid signature.
199	    pub revoked_keys: BTreeSet<String>,
200	    /// The channel this node is pinned to.
201	    pub pinned_channel: String,
202	    /// The running build's version — an update must exceed it (monotonic).
203	    pub current_version: u64,
204	    /// Wall-clock now (epoch ms) for the expiry check (caller-supplied).
205	    pub now_ms: u64,
206	}
207
208	/// The default channel pin when `release-keys.json` names none.
209	pub const DEFAULT_CHANNEL: &str = "stable";
210
211	/// The release trust anchor **compiled into every binary** (ADR-0015,
212	/// REQ-REL-3): two Ed25519 release keys — the active `primary` (signs every
213	/// release) and the never-used offline `recovery` (exists only as off-machine
214	/// seed backups; rotates in a fresh primary if the primary is ever lost).
215	/// `(key_id, verifying-key-hex)` pairs.
216	///
217	/// Minted at the v0.1.0 key ceremony (2026-06-05, `xtask release-keygen`,
218	/// run by the maintainer; both seeds custodied off-machine — the seeds never
219	/// touch this repo or CI).
220	///
221	/// `release-keys.json` (identity dir) *overlays* this set: file keys add to
222	/// it, file revocations void builtin ids too — so a compromised builtin key
223	/// is still revocable per node without a rebuild.
224	// [impl->REQ-REL-3]
225	pub const BUILTIN_RELEASE_KEYS: &[(&str, &str)] = &[
226	    (
227	        "rel-primary-2026",
228	        "54188f127f9c361090c90f85ac072e623d7001b311d25eca0dd5f57ad05e3ec9",
229	    ),
230	    (
231	        "rel-recovery-2026",
232	        "bd8908b9cbb0a1c308dd0bfb514596dd9de9ea64841528100d65efa96e834ab7",
233	    ),
234	];
235
236	/// On-disk trusted-release-keys config (`identity/release-keys.json`) — the
237	/// production [`VerifyPolicy`] source (D9-1). Human-editable JSON:
238	/// `{"keys": {"<key_id>": "<verifying-key-hex>"}, "revoked": ["<key_id>"],
239	/// "channel": "stable"}`. A missing or malformed file yields an **empty**
240	/// trusted set — every offer rejects `UnknownKey`, fail-closed: a node with
241	/// no provisioned release keys can never accept a propagated binary.
242	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
243	struct ReleaseKeysFile {
244	    #[serde(default)]
245	    keys: BTreeMap<String, String>,
246	    #[serde(default)]
247	    revoked: BTreeSet<String>,
248	    #[serde(default, skip_serializing_if = "Option::is_none")]
249	    channel: Option<String>,
250	}
251
252	impl VerifyPolicy {
253	    /// Assemble the production policy: trusted keys + channel pin from
254	    /// `release-keys.json` under the identity dir, the caller's
255	    /// `current_version` (the staged release's version, or `0` when nothing
256	    /// is staged — only strictly-newer offers pass the monotonic gate), and
257	    /// the caller's clock.
258	    // [impl->REQ-UPD-2]
259	    pub fn production(current_version: u64, now_ms: u64) -> VerifyPolicy {
260	        Self::load_from(
261	            &spt_store::perch::identity_dir().join("release-keys.json"),
262	            current_version,
263	            now_ms,
264	        )
265	    }
266
267	    /// Load from an explicit keys file (tests; the pump E2E points each
268	    /// in-process "node" at its own). Unparseable key hex is skipped loud —
269	    /// a typo must not silently widen or narrow the trusted set unnoticed.
270	    ///
271	    /// The policy starts from [`BUILTIN_RELEASE_KEYS`] (the ADR-0015 trust
272	    /// anchor every binary carries), then the file overlays it: file keys
273	    /// add (and may shadow a builtin id), file revocations void builtin ids
274	    /// too. A missing/malformed file leaves exactly the builtin set.
275	    // [impl->REQ-UPD-2]
276	    // [impl->REQ-REL-3]
277	    pub fn load_from(path: &std::path::Path, current_version: u64, now_ms: u64) -> VerifyPolicy {
278	        let file: ReleaseKeysFile = std::fs::read_to_string(path)
279	            .ok()
280	            .and_then(|raw| serde_json::from_str(&raw).ok())
281	            .unwrap_or_default();
282	        Self::assemble(BUILTIN_RELEASE_KEYS, &file, current_version, now_ms)
283	    }
284
285	    /// Merge mechanics, separated for testability with injected builtin
286	    /// pairs: builtin first, file overlays, revocations apply to both.
287	    fn assemble(
288	        builtin: &[(&str, &str)],
289	        file: &ReleaseKeysFile,
290	        current_version: u64,
291	        now_ms: u64,
292	    ) -> VerifyPolicy {
293	        let mut trusted_keys = BTreeMap::new();
294	        for (key_id, hex) in builtin {
295	            match parse_verifying_key(hex) {
296	                Ok(vk) => {
297	                    trusted_keys.insert(key_id.to_string(), vk);
298	                }
299	                // A bad builtin entry is a build defect — loud, never silent.
300	                Err(_) => eprintln!("RELEASE_KEY_BAD:builtin:{key_id} — skipped (not a valid key)"),
301	            }
302	        }
303	        for (key_id, hex) in &file.keys {
304	            match parse_verifying_key(hex) {
305	                Ok(vk) => {
306	                    trusted_keys.insert(key_id.clone(), vk);
307	                }
308	                Err(_) => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
309	            }
310	        }
311	        VerifyPolicy {
312	            trusted_keys,
313	            revoked_keys: file.revoked.clone(),
314	            pinned_channel: file
315	                .channel
316	                .clone()
317	                .unwrap_or_else(|| DEFAULT_CHANNEL.to_string()),
318	            current_version,
319	            now_ms,
320	        }
321	    }
322	}
323
324	/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
325	/// mode is a silent accept of a bad binary).
326	#[derive(Debug, Clone, PartialEq, Eq)]
327	pub enum RejectReason {
328	    /// The metadata JSON or signature hex could not be parsed.
329	    Malformed(String),
330	    /// The `key_id` is not in the trusted set.
331	    UnknownKey(String),
332	    /// The `key_id` is on the revocation list.
333	    RevokedKey(String),
334	    /// The signature did not verify against the selected key.
335	    BadSignature,
336	    /// The release targets a channel other than the node's pin.
337	    ChannelMismatch { pinned: String, got: String },
338	    /// The signed metadata has expired.
339	    Expired { now_ms: u64, expires_at_ms: u64 },
340	    /// The release version is not strictly greater than the running version.
341	    Rollback { current: u64, candidate: u64 },
342	    /// The actual binary's digest does not match the signed `artifact_sha256`.
343	    ArtifactMismatch { signed: String, actual: String },
344	    /// A signed update set has no artifact for this node's platform.
345	    NoArtifactForPlatform { platform: String },
346	}
347
348	impl std::fmt::Display for RejectReason {
349	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350	        match self {
351	            RejectReason::Malformed(e) => write!(f, "malformed release: {e}"),
352	            RejectReason::UnknownKey(k) => write!(f, "unknown signing key id {k:?}"),
353	            RejectReason::RevokedKey(k) => write!(f, "revoked signing key id {k:?}"),
354	            RejectReason::BadSignature => write!(f, "signature did not verify"),
355	            RejectReason::ChannelMismatch { pinned, got } => {
356	                write!(f, "channel {got:?} does not match pinned {pinned:?}")
357	            }
358	            RejectReason::Expired {
359	                now_ms,
360	                expires_at_ms,
361	            } => {
362	                write!(f, "release metadata expired ({now_ms} > {expires_at_ms})")
363	            }
364	            RejectReason::Rollback { current, candidate } => {
365	                write!(
366	                    f,
367	                    "rollback rejected: candidate {candidate} not greater than current {current}"
368	                )
369	            }
370	            RejectReason::ArtifactMismatch { signed, actual } => {
371	                write!(
372	                    f,
373	                    "artifact digest mismatch: signed {signed}, actual {actual}"
374	                )
375	            }
376	            RejectReason::NoArtifactForPlatform { platform } => {
377	                write!(f, "no artifact for platform {platform:?}")
378	            }
379	        }
380	    }
381	}
382
383	impl std::error::Error for RejectReason {}
384
385	/// Verify a release's **metadata + signature + hardening** against `policy`
386	/// ([`REQ-UPD-2`], [`REQ-HAZARD-UPDATE-ROLLBACK`]). Returns the authenticated
387	/// [`ReleaseMetadata`] on success. Does **not** check the binary bytes — call
388	/// [`verify_artifact`] with the downloaded binary next (the signature binds it
389	/// by digest, but the digest must still be checked against the real bytes).
390	///
391	/// Check order is deliberate: identify + authenticate the signer *first* (an
392	/// unknown/revoked key or a bad signature is rejected before any field of the
393	/// payload is trusted for a policy decision), then enforce the channel / expiry /
394	/// monotonic-version policy on the now-authenticated metadata.
395	// [impl->REQ-UPD-2]
396	// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
397	pub fn verify_metadata(
398	    signed: &SignedRelease,
399	    policy: &VerifyPolicy,
400	) -> Result<ReleaseMetadata, RejectReason> {
401	    let meta = parse_meta(signed)?;
402
403	    // A revoked key's signatures are all void — reject before spending a verify.
404	    if policy.revoked_keys.contains(&meta.key_id) {
405	        return Err(RejectReason::RevokedKey(meta.key_id));
406	    }
407	    let key = policy
408	        .trusted_keys
409	        .get(&meta.key_id)
410	        .ok_or_else(|| RejectReason::UnknownKey(meta.key_id.clone()))?;
411	    check_signature(&signed.metadata_json, &signed.signature_hex, key)?;
412
413	    // ── The metadata is now authentic; enforce the §D hardening policy. ──
414	    if meta.channel != policy.pinned_channel {
415	        return Err(RejectReason::ChannelMismatch {
416	            pinned: policy.pinned_channel.clone(),
417	            got: meta.channel,
418	        });
419	    }
420	    if policy.now_ms > meta.expires_at_ms {
421	        return Err(RejectReason::Expired {
422	            now_ms: policy.now_ms,
423	            expires_at_ms: meta.expires_at_ms,
424	        });
425	    }
426	    if meta.version <= policy.current_version {
427	        return Err(RejectReason::Rollback {
428	            current: policy.current_version,
429	            candidate: meta.version,
430	        });
431	    }
432	    Ok(meta)
433	}
434
435	/// Verify an update set's **metadata + signature + hardening** against
436	/// `policy`. The artifact bytes are still checked separately per recipient
437	/// platform with [`verify_update_set_artifact`].
438	// [impl->REQ-UPD-6]
439	pub fn verify_update_set_metadata(
440	    signed: &SignedUpdateSet,
441	    policy: &VerifyPolicy,
442	) -> Result<UpdateSetMetadata, RejectReason> {
443	    let meta = parse_update_set_meta(signed)?;
444	    if policy.revoked_keys.contains(&meta.key_id) {
445	        return Err(RejectReason::RevokedKey(meta.key_id));
446	    }
447	    let key = policy
448	        .trusted_keys
449	        .get(&meta.key_id)
450	        .ok_or_else(|| RejectReason::UnknownKey(meta.key_id.clone()))?;
451	    check_signature(&signed.metadata_json, &signed.signature_hex, key)?;
452	    if meta.channel != policy.pinned_channel {
453	        return Err(RejectReason::ChannelMismatch {
454	            pinned: policy.pinned_channel.clone(),
455	            got: meta.channel,
456	        });
457	    }
458	    if policy.now_ms > meta.expires_at_ms {
459	        return Err(RejectReason::Expired {
460	            now_ms: policy.now_ms,
461	            expires_at_ms: meta.expires_at_ms,
462	        });
463	    }
464	    if meta.version <= policy.current_version {
465	        return Err(RejectReason::Rollback {
466	            current: policy.current_version,
467	            candidate: meta.version,
468	        });
469	    }
470	    Ok(meta)
471	}
472
473	/// Verify a release's **signature only** against an explicitly-supplied key,
474	/// returning the authenticated metadata. No trust-set lookup, no
475	/// channel/expiry/rollback policy — used for **adapter content signing** (C3),
476	/// where the trusted key is the per-adapter key declared in the adapter's
477	/// manifest `[update]` rather than spt-core's release-key set, and the adapter's
478	/// payload only needs authenticity + integrity (paired with [`verify_artifact`]).
479	// [impl->REQ-UPD-5]
480	pub fn verify_signature(
481	    signed: &SignedRelease,
482	    key: &VerifyingKey,
483	) -> Result<ReleaseMetadata, RejectReason> {
484	    let meta = parse_meta(signed)?;
485	    check_signature(&signed.metadata_json, &signed.signature_hex, key)?;
486	    Ok(meta)
487	}
488
489	/// Parse the signed metadata bytes into a [`ReleaseMetadata`].
490	fn parse_meta(signed: &SignedRelease) -> Result<ReleaseMetadata, RejectReason> {
491	    serde_json::from_str(&signed.metadata_json).map_err(|e| RejectReason::Malformed(e.to_string()))
492	}
493
494	fn parse_update_set_meta(signed: &SignedUpdateSet) -> Result<UpdateSetMetadata, RejectReason> {
495	    serde_json::from_str(&signed.metadata_json).map_err(|e| RejectReason::Malformed(e.to_string()))
496	}
497
498	/// Verify the Ed25519 signature over the exact signed metadata bytes with `key`.
499	fn check_signature(
500	    metadata_json: &str,
501	    signature_hex: &str,
502	    key: &VerifyingKey,
503	) -> Result<(), RejectReason> {
504	    let sig_bytes = hex_decode(signature_hex).map_err(RejectReason::Malformed)?;
505	    let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
506	        RejectReason::Malformed(format!("signature is {} bytes, want 64", sig_bytes.len()))
507	    })?;
508	    let signature = Signature::from_bytes(&sig_arr);
509	    key.verify(metadata_json.as_bytes(), &signature)
510	        .map_err(|_| RejectReason::BadSignature)
511	}
512
513	/// Parse a hex-encoded Ed25519 public key (64 hex chars / 32 bytes) into a
514	/// [`VerifyingKey`] — the per-adapter key from a manifest `[update]` block (C3).
515	// [impl->REQ-UPD-5]
516	pub fn parse_verifying_key(hex: &str) -> Result<VerifyingKey, RejectReason> {
517	    let bytes = hex_decode(hex).map_err(RejectReason::Malformed)?;
518	    let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
519	        RejectReason::Malformed(format!("public key is {} bytes, want 32", bytes.len()))
520	    })?;
521	    VerifyingKey::from_bytes(&arr).map_err(|e| RejectReason::Malformed(e.to_string()))
522	}
523
524	/// Confirm the downloaded binary's bytes match the signed `artifact_sha256`
525	/// ([`REQ-UPD-2`]). Run after [`verify_metadata`] so the signature has already
526	/// attested to the expected digest — this closes the loop to the real bytes.
527	// [impl->REQ-UPD-2]
528	pub fn verify_artifact(meta: &ReleaseMetadata, artifact: &[u8]) -> Result<(), RejectReason> {
529	    let actual = sha256_hex(artifact);
530	    if actual == meta.artifact_sha256.to_ascii_lowercase() {
531	        Ok(())
532	    } else {
533	        Err(RejectReason::ArtifactMismatch {
534	            signed: meta.artifact_sha256.clone(),
535	            actual,
536	        })
537	    }
538	}
539
540	/// Confirm this platform's downloaded bytes match the digest inside a signed
541	/// update set.
542	// [impl->REQ-UPD-6]
543	pub fn verify_update_set_artifact(
544	    meta: &UpdateSetMetadata,
545	    platform: &str,
546	    artifact: &[u8],
547	) -> Result<(), RejectReason> {
548	    let entry = meta.artifact_for(platform)?;
549	    let actual = sha256_hex(artifact);
550	    if actual == entry.artifact_sha256.to_ascii_lowercase() {
551	        Ok(())
552	    } else {
553	        Err(RejectReason::ArtifactMismatch {
554	            signed: entry.artifact_sha256.clone(),
555	            actual,
556	        })
557	    }
558	}
559
560	/// Lowercase-hex SHA-256 of `bytes` — the artifact-digest format used in
561	/// [`ReleaseMetadata::artifact_sha256`].
562	pub fn sha256_hex(bytes: &[u8]) -> String {
563	    let mut hasher = Sha256::new();
564	    hasher.update(bytes);
565	    hex_encode(&hasher.finalize())
566	}
567
568	/// Lowercase-hex encode (kept inline to avoid a `hex` crate edge for one helper).
569	fn hex_encode(bytes: &[u8]) -> String {
570	    const HEX: &[u8; 16] = b"0123456789abcdef";
571	    let mut s = String::with_capacity(bytes.len() * 2);
572	    for b in bytes {
573	        s.push(HEX[(b >> 4) as usize] as char);
574	        s.push(HEX[(b & 0xf) as usize] as char);
575	    }
576	    s
577	}
578
579	/// Decode lowercase/uppercase hex. Errors (as a message) on odd length or a
580	/// non-hex digit.
581	fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
582	    if !s.len().is_multiple_of(2) {
583	        return Err(format!("hex length {} is odd", s.len()));
584	    }
585	    let val = |c: u8| -> Result<u8, String> {
586	        match c {
587	            b'0'..=b'9' => Ok(c - b'0'),
588	            b'a'..=b'f' => Ok(c - b'a' + 10),
589	            b'A'..=b'F' => Ok(c - b'A' + 10),
590	            other => Err(format!("non-hex byte {other:#x}")),
591	        }
592	    };
593	    let b = s.as_bytes();
594	    let mut out = Vec::with_capacity(b.len() / 2);
595	    for pair in b.chunks_exact(2) {
596	        out.push((val(pair[0])? << 4) | val(pair[1])?);
597	    }
598	    Ok(out)
599	}
600
601	#[cfg(test)]
602	mod tests {
603	    use super::*;
604	    use crate::update::BROKER_RESOURCE_ABI;
605	    use ed25519_dalek::{Signer, SigningKey};
606
607	    const KEY_ID: &str = "rel-2026";
608	    const CHANNEL: &str = "stable";
609
610	    /// A deterministic signing key (no RNG — test-stable). Real release tooling
611	    /// mints one from a CSPRNG; verification is identical either way.
612	    fn signing_key(seed: u8) -> SigningKey {
613	        SigningKey::from_bytes(&[seed; 32])
614	    }
615
616	    /// Build + sign a release for `artifact`, returning the wire form.
617	    fn sign_release(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
618	        let metadata_json = serde_json::to_string(meta).unwrap();
619	        let sig = sk.sign(metadata_json.as_bytes());
620	        SignedRelease {
621	            metadata_json,
622	            signature_hex: hex_encode(&sig.to_bytes()),
623	        }
624	    }
625
626	    fn sign_update_set(sk: &SigningKey, meta: &UpdateSetMetadata) -> SignedUpdateSet {
627	        let metadata_json = serde_json::to_string(meta).unwrap();
628	        let sig = sk.sign(metadata_json.as_bytes());
629	        SignedUpdateSet {
630	            metadata_json,
631	            signature_hex: hex_encode(&sig.to_bytes()),
632	        }
633	    }
634
635	    fn meta_for(version: u64, artifact: &[u8]) -> ReleaseMetadata {
636	        ReleaseMetadata {
637	            version,
638	            channel: CHANNEL.to_string(),
639	            expires_at_ms: 10_000,
640	            key_id: KEY_ID.to_string(),
641	            artifact_sha256: sha256_hex(artifact),
642	            brain_ipc_version: 1,
643	            broker_resource_abi: BROKER_RESOURCE_ABI,
644	        }
645	    }
646
647	    fn set_meta(version: u64, win: &[u8], linux: &[u8]) -> UpdateSetMetadata {
648	        UpdateSetMetadata {
649	            version,
650	            channel: CHANNEL.to_string(),
651	            expires_at_ms: 10_000,
652	            key_id: KEY_ID.to_string(),
653	            artifacts: BTreeMap::from([
654	                (
655	                    "x86_64-pc-windows-msvc".to_string(),
656	                    UpdateArtifactMetadata {
657	                        artifact_sha256: sha256_hex(win),
658	                        brain_ipc_version: 1,
659	                        broker_resource_abi: BROKER_RESOURCE_ABI,
660	                        asset_name: Some("spt-x86_64-windows.exe".to_string()),
661	                    },
662	                ),
663	                (
664	                    "x86_64-unknown-linux-gnu".to_string(),
665	                    UpdateArtifactMetadata {
666	                        artifact_sha256: sha256_hex(linux),
667	                        brain_ipc_version: 1,
668	                        broker_resource_abi: BROKER_RESOURCE_ABI,
669	                        asset_name: Some("spt-x86_64-linux".to_string()),
670	                    },
671	                ),
672	            ]),
673	            provenance: Some(UpdateSetProvenance {
674	                git_commit: Some("abc123".to_string()),
675	                git_dirty: Some(true),
676	                build_host: Some("fastbox".to_string()),
677	                build_timestamp_ms: Some(42),
678	            }),
679	        }
680	    }
681
682	    // [unit->REQ-UPD-2] the production keys file loads: valid keys trusted,
683	    // bad hex skipped, channel pinned. Since M6-D6 a missing file no longer
684	    // means an empty set — every binary carries BUILTIN_RELEASE_KEYS (the
685	    // ADR-0015 anchor), and the file is a pure overlay on top of it.
686	    #[test]
687	    fn production_policy_loads_fail_closed() {
688	        let dir = tempfile::tempdir().unwrap();
689	        let path = dir.path().join("release-keys.json");
690
691	        // Missing file: exactly the embedded builtin set, default channel.
692	        let p = VerifyPolicy::load_from(&path, 3, 1_000);
693	        assert_eq!(p.trusted_keys.len(), BUILTIN_RELEASE_KEYS.len());
694	        for (key_id, _) in BUILTIN_RELEASE_KEYS {
695	            assert!(
696	                p.trusted_keys.contains_key(*key_id),
697	                "builtin {key_id} trusted"
698	            );
699	        }
700	        assert_eq!(p.pinned_channel, DEFAULT_CHANNEL);
701	        assert_eq!((p.current_version, p.now_ms), (3, 1_000));
702
703	        // A real key + a malformed one + a revocation + a channel pin: the
704	        // file OVERLAYS the builtin set (adds, never replaces).
705	        let vk = signing_key(9).verifying_key();
706	        let json = serde_json::json!({
707	            "keys": { "good": hex_encode(vk.as_bytes()), "bad": "zz-not-hex" },
708	            "revoked": ["old-2024"],
709	            "channel": "beta",
710	        });
711	        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
712	        let p = VerifyPolicy::load_from(&path, 5, 2_000);
713	        assert_eq!(
714	            p.trusted_keys.len(),
715	            BUILTIN_RELEASE_KEYS.len() + 1,
716	            "bad hex skipped, good key added to the builtin set"
717	        );
718	        assert!(p.trusted_keys.contains_key("good"));
719	        assert!(p.revoked_keys.contains("old-2024"));
720	        assert_eq!(p.pinned_channel, "beta");
721
722	        // Malformed file degrades to the builtin set, never a panic and
723	        // never a widened set.
724	        std::fs::write(&path, "not json {{{").unwrap();
725	        assert_eq!(
726	            VerifyPolicy::load_from(&path, 0, 0).trusted_keys.len(),
727	            BUILTIN_RELEASE_KEYS.len()
728	        );
729	    }
730
731	    fn policy(sk: &SigningKey) -> VerifyPolicy {
732	        let mut trusted = BTreeMap::new();
733	        trusted.insert(KEY_ID.to_string(), sk.verifying_key());
734	        VerifyPolicy {
735	            trusted_keys: trusted,
736	            revoked_keys: BTreeSet::new(),
737	            pinned_channel: CHANNEL.to_string(),
738	            current_version: 5,
739	            now_ms: 1_000, // before the 10_000 expiry
740	        }
741	    }
742
743	    // [unit->REQ-UPD-2] a correctly-signed, newer, in-channel, unexpired release
744	    // from a trusted key verifies, and its bytes match the signed digest.
745	    #[test]
746	    fn valid_release_verifies_and_artifact_matches() {
747	        let sk = signing_key(7);
748	        let artifact = b"the new spt binary bytes";
749	        let signed = sign_release(&sk, &meta_for(6, artifact));
750
751	        let meta = verify_metadata(&signed, &policy(&sk)).expect("valid release verifies");
752	        assert_eq!(meta.version, 6);
753	        verify_artifact(&meta, artifact).expect("artifact digest matches the signed value");
754	        assert_eq!(meta.spec().broker_resource_abi, BROKER_RESOURCE_ABI);
755	    }
756
757	    // [unit->REQ-UPD-2] tampering with the signed bytes after signing ⇒ the
758	    // signature no longer verifies (the metadata is not authentic).
759	    #[test]
760	    fn tampered_metadata_fails_signature() {
761	        let sk = signing_key(7);
762	        let mut signed = sign_release(&sk, &meta_for(6, b"bin"));
763	        // Flip the version in the JSON without re-signing.
764	        signed.metadata_json = signed
765	            .metadata_json
766	            .replace("\"version\":6", "\"version\":9");
767	        assert_eq!(
768	            verify_metadata(&signed, &policy(&sk)),
769	            Err(RejectReason::BadSignature)
770	        );
771	    }
772
773	    // [unit->REQ-UPD-2] a signature from a key the node does not trust ⇒ UnknownKey.
774	    #[test]
775	    fn untrusted_key_is_unknown() {
776	        let real = signing_key(7);
777	        let attacker = signing_key(9);
778	        let signed = sign_release(&attacker, &meta_for(6, b"bin"));
779	        // Policy trusts only `real` under KEY_ID; the attacker signed under the
780	        // same key_id but with a different key → signature won't match the trusted
781	        // verifying key. (UnknownKey would fire if the id itself were untrusted.)
782	        assert_eq!(
783	            verify_metadata(&signed, &policy(&real)),
784	            Err(RejectReason::BadSignature)
785	        );
786
787	        // A genuinely unknown key_id is UnknownKey.
788	        let mut meta = meta_for(6, b"bin");
789	        meta.key_id = "no-such-key".to_string();
790	        let signed2 = sign_release(&attacker, &meta);
791	        assert_eq!(
792	            verify_metadata(&signed2, &policy(&real)),
793	            Err(RejectReason::UnknownKey("no-such-key".to_string()))
794	        );
795	    }
796
797	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a revoked key is rejected even with an
798	    // otherwise-valid signature.
799	    #[test]
800	    fn revoked_key_is_rejected() {
801	        let sk = signing_key(7);
802	        let signed = sign_release(&sk, &meta_for(6, b"bin"));
803	        let mut pol = policy(&sk);
804	        pol.revoked_keys.insert(KEY_ID.to_string());
805	        assert_eq!(
806	            verify_metadata(&signed, &pol),
807	            Err(RejectReason::RevokedKey(KEY_ID.to_string()))
808	        );
809	    }
810
811	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a version not strictly greater than the
812	    // running build is a rollback (both equal and lower are rejected).
813	    #[test]
814	    fn non_monotonic_version_is_rollback() {
815	        let sk = signing_key(7);
816	        let pol = policy(&sk); // current_version = 5
817	        for candidate in [5u64, 4, 0] {
818	            let signed = sign_release(&sk, &meta_for(candidate, b"bin"));
819	            assert_eq!(
820	                verify_metadata(&signed, &pol),
821	                Err(RejectReason::Rollback {
822	                    current: 5,
823	                    candidate
824	                }),
825	                "version {candidate} must be rejected as a rollback"
826	            );
827	        }
828	    }
829
830	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] expired signed metadata is rejected even
831	    // when everything else is valid (replay of an old-but-signed release).
832	    #[test]
833	    fn expired_metadata_is_rejected() {
834	        let sk = signing_key(7);
835	        let signed = sign_release(&sk, &meta_for(6, b"bin")); // expires_at_ms = 10_000
836	        let mut pol = policy(&sk);
837	        pol.now_ms = 20_000; // past expiry
838	        assert_eq!(
839	            verify_metadata(&signed, &pol),
840	            Err(RejectReason::Expired {
841	                now_ms: 20_000,
842	                expires_at_ms: 10_000
843	            })
844	        );
845	    }
846
847	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a release for another channel is rejected
848	    // (channel pinning: a beta build cannot land on a stable node).
849	    #[test]
850	    fn channel_mismatch_is_rejected() {
851	        let sk = signing_key(7);
852	        let mut meta = meta_for(6, b"bin");
853	        meta.channel = "beta".to_string();
854	        let signed = sign_release(&sk, &meta);
855	        assert_eq!(
856	            verify_metadata(&signed, &policy(&sk)),
857	            Err(RejectReason::ChannelMismatch {
858	                pinned: CHANNEL.to_string(),
859	                got: "beta".to_string()
860	            })
861	        );
862	    }
863
864	    // [unit->REQ-UPD-2] a binary whose bytes do not hash to the signed digest is
865	    // rejected — the signature attests to specific bytes, not just a manifest.
866	    #[test]
867	    fn artifact_digest_mismatch_is_rejected() {
868	        let sk = signing_key(7);
869	        let signed = sign_release(&sk, &meta_for(6, b"the signed bytes"));
870	        let meta = verify_metadata(&signed, &policy(&sk)).expect("metadata verifies");
871	        // A different binary than the one whose digest was signed.
872	        let tampered = b"DIFFERENT bytes than were signed";
873	        assert!(matches!(
874	            verify_artifact(&meta, tampered),
875	            Err(RejectReason::ArtifactMismatch { .. })
876	        ));
877	    }
878
879	    // [unit->REQ-UPD-6] a platform-targeted update set is signed as one
880	    // metadata document, then each recipient selects/verifies only its own
881	    // Rust-target-triple artifact.
882	    #[test]
883	    fn update_set_verifies_and_selects_platform_artifact() {
884	        let sk = signing_key(7);
885	        let signed = sign_update_set(&sk, &set_meta(6, b"win bytes", b"linux bytes"));
886	        let meta =
887	            verify_update_set_metadata(&signed, &policy(&sk)).expect("set metadata verifies");
888
889	        let linux = meta.artifact_for("x86_64-unknown-linux-gnu").unwrap();
890	        assert_eq!(linux.asset_name.as_deref(), Some("spt-x86_64-linux"));
891	        verify_update_set_artifact(&meta, "x86_64-unknown-linux-gnu", b"linux bytes")
892	            .expect("linux artifact digest matches");
893	        verify_update_set_artifact(&meta, "x86_64-pc-windows-msvc", b"win bytes")
894	            .expect("windows artifact digest matches");
895	        assert_eq!(
896	            meta.provenance.as_ref().unwrap().build_host.as_deref(),
897	            Some("fastbox")
898	        );
899	    }
900
901	    // [unit->REQ-UPD-6] missing local-platform artifacts are loud typed
902	    // rejections, not "up to date" no-ops.
903	    #[test]
904	    fn update_set_missing_platform_is_typed_rejection() {
905	        let sk = signing_key(7);
906	        let mut meta = set_meta(6, b"win bytes", b"linux bytes");
907	        meta.artifacts.remove("x86_64-pc-windows-msvc");
908	        let signed = sign_update_set(&sk, &meta);
909	        let meta = verify_update_set_metadata(&signed, &policy(&sk)).unwrap();
910	        assert_eq!(
911	            verify_update_set_artifact(&meta, "x86_64-pc-windows-msvc", b"win bytes"),
912	            Err(RejectReason::NoArtifactForPlatform {
913	                platform: "x86_64-pc-windows-msvc".to_string()
914	            })
915	        );
916	    }
917
918	    // [unit->REQ-UPD-6] the whole set is signed: modifying any platform digest
919	    // after signing invalidates the single set signature.
920	    #[test]
921	    fn update_set_tampering_breaks_whole_set_signature() {
922	        let sk = signing_key(7);
923	        let mut signed = sign_update_set(&sk, &set_meta(6, b"win bytes", b"linux bytes"));
924	        signed.metadata_json = signed
925	            .metadata_json
926	            .replace(&sha256_hex(b"linux bytes"), &sha256_hex(b"evil bytes"));
927	        assert_eq!(
928	            verify_update_set_metadata(&signed, &policy(&sk)),
929	            Err(RejectReason::BadSignature)
930	        );
931	    }
932
933	    // [unit->REQ-UPD-6] update sets still use channel pinning + monotonic
934	    // counters, scoped to the policy's pinned channel.
935	    #[test]
936	    fn update_set_channel_and_rollback_gates_hold() {
937	        let sk = signing_key(7);
938	        let mut wrong_channel = set_meta(6, b"win", b"linux");
939	        wrong_channel.channel = "debug".to_string();
940	        let signed = sign_update_set(&sk, &wrong_channel);
941	        assert_eq!(
942	            verify_update_set_metadata(&signed, &policy(&sk)),
943	            Err(RejectReason::ChannelMismatch {
944	                pinned: CHANNEL.to_string(),
945	                got: "debug".to_string()
946	            })
947	        );
948
949	        let rollback = sign_update_set(&sk, &set_meta(5, b"win", b"linux"));
950	        assert_eq!(
951	            verify_update_set_metadata(&rollback, &policy(&sk)),
952	            Err(RejectReason::Rollback {
953	                current: 5,
954	                candidate: 5
955	            })
956	        );
957	    }
958
959	    // [unit->REQ-UPD-6] a bytes/digest mismatch on the selected platform is
960	    // rejected even though the set metadata itself verifies.
961	    #[test]
962	    fn update_set_artifact_mismatch_is_rejected() {
963	        let sk = signing_key(7);
964	        let signed = sign_update_set(&sk, &set_meta(6, b"win bytes", b"linux bytes"));
965	        let meta = verify_update_set_metadata(&signed, &policy(&sk)).unwrap();
966	        assert!(matches!(
967	            verify_update_set_artifact(&meta, "x86_64-unknown-linux-gnu", b"wrong"),
968	            Err(RejectReason::ArtifactMismatch { .. })
969	        ));
970	    }
971
972	    // ---- M6-D6: the embedded two-key trust anchor (ADR-0015) ----
973
974	    /// Builtin pairs merge under a file overlay: builtin keys trusted with no
975	    /// file at all; file keys add; file revocations void builtin ids too
976	    /// (compromise recovery without a rebuild).
977	    // [unit->REQ-REL-3]
978	    #[test]
979	    fn builtin_keys_merge_and_revoke_under_file_overlay() {
980	        let primary = signing_key(31);
981	        let recovery = signing_key(32);
982	        let p_hex = hex_encode(primary.verifying_key().as_bytes());
983	        let r_hex = hex_encode(recovery.verifying_key().as_bytes());
984	        let builtin: Vec<(&str, &str)> = vec![
985	            ("rel-primary", p_hex.as_str()),
986	            ("rel-recovery", r_hex.as_str()),
987	        ];
988
989	        // No file: exactly the builtin set, default channel.
990	        let pol = VerifyPolicy::assemble(&builtin, &ReleaseKeysFile::default(), 0, 0);
991	        assert_eq!(pol.trusted_keys.len(), 2);
992	        assert!(pol.trusted_keys.contains_key("rel-primary"));
993	        assert!(pol.trusted_keys.contains_key("rel-recovery"));
994
995	        // File overlay: adds a key AND revokes the builtin primary.
996	        let extra = signing_key(33);
997	        let file = ReleaseKeysFile {
998	            keys: BTreeMap::from([(
999	                "node-extra".to_string(),
1000	                hex_encode(extra.verifying_key().as_bytes()),
1001	            )]),
1002	            revoked: BTreeSet::from(["rel-primary".to_string()]),
1003	            channel: None,
1004	        };
1005	        let pol = VerifyPolicy::assemble(&builtin, &file, 5, 1_000);
1006	        assert_eq!(
1007	            pol.trusted_keys.len(),
1008	            3,
1009	            "file key added to the builtin set"
1010	        );
1011
1012	        // The revoked BUILTIN primary is rejected even with a valid signature…
1013	        let mut meta = meta_for(6, b"bin");
1014	        meta.key_id = "rel-primary".to_string();
1015	        let signed = sign_release(&primary, &meta);
1016	        let mut pol2 = pol.clone();
1017	        pol2.pinned_channel = CHANNEL.to_string();
1018	        assert_eq!(
1019	            verify_metadata(&signed, &pol2),
1020	            Err(RejectReason::RevokedKey("rel-primary".to_string()))
1021	        );
1022
1023	        // …while the same release signed by the RECOVERY key verifies: the
1024	        // ADR-0015 loss/compromise story — the second already-trusted key
1025	        // keeps the update chain alive.
1026	        let mut meta_r = meta_for(6, b"bin");
1027	        meta_r.key_id = "rel-recovery".to_string();
1028	        let signed_r = sign_release(&recovery, &meta_r);
1029	        let got = verify_metadata(&signed_r, &pol2).expect("recovery-signed release verifies");
1030	        assert_eq!(got.key_id, "rel-recovery");
1031	    }
1032
1033	    /// The shipped builtin table itself parses clean: every entry must be a
1034	    /// valid (id, 64-hex Ed25519 key) pair — a typo at the ceremony fails the
1035	    /// build's tests, not a node in the field.
1036	    // [unit->REQ-REL-3]
1037	    #[test]
1038	    fn shipped_builtin_table_is_well_formed() {
1039	        for (key_id, hex) in BUILTIN_RELEASE_KEYS {
1040	            parse_verifying_key(hex)
1041	                .unwrap_or_else(|e| panic!("builtin release key {key_id:?} invalid: {e}"));
1042	            assert!(!key_id.trim().is_empty());
1043	        }
1044	    }
1045
1046	    #[test]
1047	    fn hex_roundtrips() {
1048	        let bytes = [0u8, 1, 15, 16, 255, 128, 7];
1049	        assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
1050	        assert!(hex_decode("abc").is_err()); // odd length
1051	        assert!(hex_decode("zz").is_err()); // non-hex
1052	    }
1053	}
1054

//! Release metadata + signature verification (C1) — the gate that decides which
//! binary is even *eligible* to be handed off (ADR-0004 §D).
//!
//! ADR-0004 makes signing **mandatory**: peer-propagation (M4) otherwise lets one
//! compromised node poison the subnet, so every binary is signature-verified
//! before handoff regardless of source ([`REQ-UPD-2`]). Signature alone is
//! insufficient (codex SERIOUS #5), so v1 also enforces the
//! [`REQ-HAZARD-UPDATE-ROLLBACK`] hardening set:
//!
//! - **monotonic version** — a release whose version is not strictly greater than
//!   the running build is rejected (downgrade attack / accidental rollback);
//! - **metadata expiry** — signed metadata past its `expires_at_ms` is rejected
//!   (a captured old-but-valid release cannot be replayed forever);
//! - **channel pinning** — a release for a channel other than the pinned one is
//!   rejected (a `beta` build cannot be slipped onto a `stable` node);
//! - **key rotation + revocation** — the signing key is selected by `key_id` from
//!   a trusted set (rotation = add a key), and a revoked `key_id` is rejected even
//!   with an otherwise-valid signature.
//!
//! The signature covers the **metadata**, and the metadata binds the binary by
//! its SHA-256 ([`ReleaseMetadata::artifact_sha256`]) — so [`verify_artifact`]
//! confirms the actual bytes match what was signed. The two together mean a valid
//! signature attests to *these exact binary bytes*, not just a manifest.
//!
//! Verification is pure (no I/O, no clock): the caller passes `now_ms` and the
//! artifact bytes, so the policy is fully testable and the same code runs in the
//! daemon and in tests. The C0 [`crate::update`] engine consumes the verified
//! release; this module never triggers a handoff itself.
// [impl->REQ-UPD-2]

use std::collections::{BTreeMap, BTreeSet};

use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::update::ReleaseSpec;

/// The signed description of a release. Serialized to the exact bytes that get
/// signed; the signature in [`SignedRelease`] is computed over `metadata_json`'s
/// UTF-8 bytes verbatim, so verification never re-serializes (no canonicalization
/// dependency — we verify the bytes we were given).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleaseMetadata {
    /// Monotonic release version. An update is accepted only if this is strictly
    /// greater than the running build's version (rollback rejection).
    pub version: u64,
    /// The release channel (e.g. `stable`, `beta`). Must equal the node's pin.
    pub channel: String,
    /// Wall-clock expiry of this signed metadata (epoch ms). Past it ⇒ rejected.
    pub expires_at_ms: u64,
    /// Which release key signed this — selects the verifying key + drives
    /// rotation/revocation.
    pub key_id: String,
    /// Lowercase-hex SHA-256 of the release binary the signature attests to.
    pub artifact_sha256: String,
    /// The IPC protocol version the release's **brain** speaks (ADR-0004 §A axis,
    /// fed to [`crate::update::classify`] once the release is verified).
    pub brain_ipc_version: u32,
    /// The held-resource ABI the release's **broker** implements (ADR-0004 §A axis).
    pub broker_resource_abi: u32,
}

impl ReleaseMetadata {
    /// The update-class inputs this verified release carries (ADR-0004 §A).
    pub fn spec(&self) -> ReleaseSpec {
        ReleaseSpec {
            brain_ipc_version: self.brain_ipc_version,
            broker_resource_abi: self.broker_resource_abi,
        }
    }
}

/// A release's signed metadata: the exact signed bytes plus the detached Ed25519
/// signature (lowercase hex, 64 bytes / 128 hex chars). Transport/on-disk shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRelease {
    /// The exact UTF-8 bytes that were signed (a serialized [`ReleaseMetadata`]).
    pub metadata_json: String,
    /// The Ed25519 signature over `metadata_json`'s bytes, lowercase hex.
    pub signature_hex: String,
}

/// The artifact entry inside a platform-targeted update set. The map key is
/// the Rust target triple (e.g. `x86_64-pc-windows-msvc`); this entry carries
/// the digest and update-class axes for that platform's binary.
// [impl->REQ-UPD-6]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateArtifactMetadata {
    /// Lowercase-hex SHA-256 of this platform's binary.
    pub artifact_sha256: String,
    /// The IPC protocol version this platform's **brain** speaks.
    pub brain_ipc_version: u32,
    /// The held-resource ABI this platform's **broker** implements.
    pub broker_resource_abi: u32,
    /// Optional human/package-facing asset name; not used for trust.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub asset_name: Option<String>,
}

impl UpdateArtifactMetadata {
    /// The update-class inputs for this platform artifact.
    pub fn spec(&self) -> ReleaseSpec {
        ReleaseSpec {
            brain_ipc_version: self.brain_ipc_version,
            broker_resource_abi: self.broker_resource_abi,
        }
    }
}

/// Signed diagnostic provenance for debug rollout observability. None of these
/// fields affect trust; they answer "which bits are running where?"
// [impl->REQ-UPD-6]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct UpdateSetProvenance {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git_commit: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git_dirty: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build_host: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build_timestamp_ms: Option<u64>,
}

/// A signed update set's metadata: one release version/channel/key and a set of
/// per-platform artifacts keyed by Rust target triple.
// [impl->REQ-UPD-6]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateSetMetadata {
    /// Monotonic release version within `channel`.
    pub version: u64,
    /// The release channel. A node accepts only its pinned channel.
    pub channel: String,
    /// Wall-clock expiry of this signed metadata (epoch ms).
    pub expires_at_ms: u64,
    /// Which release key signed this set.
    pub key_id: String,
    /// Platform artifacts keyed by Rust target triple.
    pub artifacts: BTreeMap<String, UpdateArtifactMetadata>,
    /// Optional signed diagnostics for debugging and fleet inspection.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provenance: Option<UpdateSetProvenance>,
}

impl UpdateSetMetadata {
    /// The artifact entry for `platform`, or the typed rejection that powers
    /// debug-rollout convergence tables.
    // [impl->REQ-UPD-6]
    pub fn artifact_for(&self, platform: &str) -> Result<&UpdateArtifactMetadata, RejectReason> {
        self.artifacts
            .get(platform)
            .ok_or_else(|| RejectReason::NoArtifactForPlatform {
                platform: platform.to_string(),
            })
    }
}

/// A signed update set: exact signed metadata bytes plus detached Ed25519
/// signature. The signature covers all platform artifact digests.
// [impl->REQ-UPD-6]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedUpdateSet {
    /// The exact UTF-8 bytes that were signed (serialized [`UpdateSetMetadata`]).
    pub metadata_json: String,
    /// The Ed25519 signature over `metadata_json`'s bytes, lowercase hex.
    pub signature_hex: String,
}

/// Canonical platform identifier for this build, matching Rust target triples
/// used by Cargo and ADR-0016.
// [impl->REQ-UPD-6]
pub fn current_platform() -> &'static str {
    #[cfg(all(target_arch = "x86_64", target_os = "windows", target_env = "msvc"))]
    {
        "x86_64-pc-windows-msvc"
    }
    #[cfg(all(target_arch = "x86_64", target_os = "linux", target_env = "gnu"))]
    {
        "x86_64-unknown-linux-gnu"
    }
    #[cfg(not(any(
        all(target_arch = "x86_64", target_os = "windows", target_env = "msvc"),
        all(target_arch = "x86_64", target_os = "linux", target_env = "gnu")
    )))]
    {
        "unknown"
    }
}

/// The trust policy a node verifies a release against. Pure data — no clock, no
/// I/O — so verification is deterministic and testable.
#[derive(Debug, Clone)]
pub struct VerifyPolicy {
    /// Trusted release keys by `key_id`. Rotation = add a new id; old ids may
    /// linger until retired.
    pub trusted_keys: BTreeMap<String, VerifyingKey>,
    /// Explicitly revoked `key_id`s — rejected even with a valid signature.
    pub revoked_keys: BTreeSet<String>,
    /// The channel this node is pinned to.
    pub pinned_channel: String,
    /// The running build's version — an update must exceed it (monotonic).
    pub current_version: u64,
    /// Wall-clock now (epoch ms) for the expiry check (caller-supplied).
    pub now_ms: u64,
}

/// The default channel pin when `release-keys.json` names none.
pub const DEFAULT_CHANNEL: &str = "stable";

/// The release trust anchor **compiled into every binary** (ADR-0015,
/// REQ-REL-3): two Ed25519 release keys — the active `primary` (signs every
/// release) and the never-used offline `recovery` (exists only as off-machine
/// seed backups; rotates in a fresh primary if the primary is ever lost).
/// `(key_id, verifying-key-hex)` pairs.
///
/// Minted at the v0.1.0 key ceremony (2026-06-05, `xtask release-keygen`,
/// run by the maintainer; both seeds custodied off-machine — the seeds never
/// touch this repo or CI).
///
/// `release-keys.json` (identity dir) *overlays* this set: file keys add to
/// it, file revocations void builtin ids too — so a compromised builtin key
/// is still revocable per node without a rebuild.
// [impl->REQ-REL-3]
pub const BUILTIN_RELEASE_KEYS: &[(&str, &str)] = &[
    (
        "rel-primary-2026",
        "54188f127f9c361090c90f85ac072e623d7001b311d25eca0dd5f57ad05e3ec9",
    ),
    (
        "rel-recovery-2026",
        "bd8908b9cbb0a1c308dd0bfb514596dd9de9ea64841528100d65efa96e834ab7",
    ),
];

/// On-disk trusted-release-keys config (`identity/release-keys.json`) — the
/// production [`VerifyPolicy`] source (D9-1). Human-editable JSON:
/// `{"keys": {"<key_id>": "<verifying-key-hex>"}, "revoked": ["<key_id>"],
/// "channel": "stable"}`. A missing or malformed file yields an **empty**
/// trusted set — every offer rejects `UnknownKey`, fail-closed: a node with
/// no provisioned release keys can never accept a propagated binary.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ReleaseKeysFile {
    #[serde(default)]
    keys: BTreeMap<String, String>,
    #[serde(default)]
    revoked: BTreeSet<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    channel: Option<String>,
}

impl VerifyPolicy {
    /// Assemble the production policy: trusted keys + channel pin from
    /// `release-keys.json` under the identity dir, the caller's
    /// `current_version` (the staged release's version, or `0` when nothing
    /// is staged — only strictly-newer offers pass the monotonic gate), and
    /// the caller's clock.
    // [impl->REQ-UPD-2]
    pub fn production(current_version: u64, now_ms: u64) -> VerifyPolicy {
        Self::load_from(
            &spt_store::perch::identity_dir().join("release-keys.json"),
            current_version,
            now_ms,
        )
    }

    /// Load from an explicit keys file (tests; the pump E2E points each
    /// in-process "node" at its own). Unparseable key hex is skipped loud —
    /// a typo must not silently widen or narrow the trusted set unnoticed.
    ///
    /// The policy starts from [`BUILTIN_RELEASE_KEYS`] (the ADR-0015 trust
    /// anchor every binary carries), then the file overlays it: file keys
    /// add (and may shadow a builtin id), file revocations void builtin ids
    /// too. A missing/malformed file leaves exactly the builtin set.
    // [impl->REQ-UPD-2]
    // [impl->REQ-REL-3]
    pub fn load_from(path: &std::path::Path, current_version: u64, now_ms: u64) -> VerifyPolicy {
        let file: ReleaseKeysFile = std::fs::read_to_string(path)
            .ok()
            .and_then(|raw| serde_json::from_str(&raw).ok())
            .unwrap_or_default();
        Self::assemble(BUILTIN_RELEASE_KEYS, &file, current_version, now_ms)
    }

    /// Merge mechanics, separated for testability with injected builtin
    /// pairs: builtin first, file overlays, revocations apply to both.
    fn assemble(
        builtin: &[(&str, &str)],
        file: &ReleaseKeysFile,
        current_version: u64,
        now_ms: u64,
    ) -> VerifyPolicy {
        let mut trusted_keys = BTreeMap::new();
        for (key_id, hex) in builtin {
            match parse_verifying_key(hex) {
                Ok(vk) => {
                    trusted_keys.insert(key_id.to_string(), vk);
                }
                // A bad builtin entry is a build defect — loud, never silent.
                Err(_) => eprintln!("RELEASE_KEY_BAD:builtin:{key_id} — skipped (not a valid key)"),
            }
        }
        for (key_id, hex) in &file.keys {
            match parse_verifying_key(hex) {
                Ok(vk) => {
                    trusted_keys.insert(key_id.clone(), vk);
                }
                Err(_) => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
            }
        }
        VerifyPolicy {
            trusted_keys,
            revoked_keys: file.revoked.clone(),
            pinned_channel: file
                .channel
                .clone()
                .unwrap_or_else(|| DEFAULT_CHANNEL.to_string()),
            current_version,
            now_ms,
        }
    }
}

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RejectReason {
    /// The metadata JSON or signature hex could not be parsed.
    Malformed(String),
    /// The `key_id` is not in the trusted set.
    UnknownKey(String),
    /// The `key_id` is on the revocation list.
    RevokedKey(String),
    /// The signature did not verify against the selected key.
    BadSignature,
    /// The release targets a channel other than the node's pin.
    ChannelMismatch { pinned: String, got: String },
    /// The signed metadata has expired.
    Expired { now_ms: u64, expires_at_ms: u64 },
    /// The release version is not strictly greater than the running version.
    Rollback { current: u64, candidate: u64 },
    /// The actual binary's digest does not match the signed `artifact_sha256`.
    ArtifactMismatch { signed: String, actual: String },
    /// A signed update set has no artifact for this node's platform.
    NoArtifactForPlatform { platform: String },
}

impl std::fmt::Display for RejectReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RejectReason::Malformed(e) => write!(f, "malformed release: {e}"),
            RejectReason::UnknownKey(k) => write!(f, "unknown signing key id {k:?}"),
            RejectReason::RevokedKey(k) => write!(f, "revoked signing key id {k:?}"),
            RejectReason::BadSignature => write!(f, "signature did not verify"),
            RejectReason::ChannelMismatch { pinned, got } => {
                write!(f, "channel {got:?} does not match pinned {pinned:?}")
            }
            RejectReason::Expired {
                now_ms,
                expires_at_ms,
            } => {
                write!(f, "release metadata expired ({now_ms} > {expires_at_ms})")
            }
            RejectReason::Rollback { current, candidate } => {
                write!(
                    f,
                    "rollback rejected: candidate {candidate} not greater than current {current}"
                )
            }
            RejectReason::ArtifactMismatch { signed, actual } => {
                write!(
                    f,
                    "artifact digest mismatch: signed {signed}, actual {actual}"
                )
            }
            RejectReason::NoArtifactForPlatform { platform } => {
                write!(f, "no artifact for platform {platform:?}")
            }
        }
    }
}

impl std::error::Error for RejectReason {}

/// Verify a release's **metadata + signature + hardening** against `policy`
/// ([`REQ-UPD-2`], [`REQ-HAZARD-UPDATE-ROLLBACK`]). Returns the authenticated
/// [`ReleaseMetadata`] on success. Does **not** check the binary bytes — call
/// [`verify_artifact`] with the downloaded binary next (the signature binds it
/// by digest, but the digest must still be checked against the real bytes).
///
/// Check order is deliberate: identify + authenticate the signer *first* (an
/// unknown/revoked key or a bad signature is rejected before any field of the
/// payload is trusted for a policy decision), then enforce the channel / expiry /
/// monotonic-version policy on the now-authenticated metadata.
// [impl->REQ-UPD-2]
// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
pub fn verify_metadata(
    signed: &SignedRelease,
    policy: &VerifyPolicy,
) -> Result<ReleaseMetadata, RejectReason> {
    let meta = parse_meta(signed)?;

    // A revoked key's signatures are all void — reject before spending a verify.
    if policy.revoked_keys.contains(&meta.key_id) {
        return Err(RejectReason::RevokedKey(meta.key_id));
    }
    let key = policy
        .trusted_keys
        .get(&meta.key_id)
        .ok_or_else(|| RejectReason::UnknownKey(meta.key_id.clone()))?;
    check_signature(&signed.metadata_json, &signed.signature_hex, key)?;

    // ── The metadata is now authentic; enforce the §D hardening policy. ──
    if meta.channel != policy.pinned_channel {
        return Err(RejectReason::ChannelMismatch {
            pinned: policy.pinned_channel.clone(),
            got: meta.channel,
        });
    }
    if policy.now_ms > meta.expires_at_ms {
        return Err(RejectReason::Expired {
            now_ms: policy.now_ms,
            expires_at_ms: meta.expires_at_ms,
        });
    }
    if meta.version <= policy.current_version {
        return Err(RejectReason::Rollback {
            current: policy.current_version,
            candidate: meta.version,
        });
    }
    Ok(meta)
}

/// Verify an update set's **metadata + signature + hardening** against
/// `policy`. The artifact bytes are still checked separately per recipient
/// platform with [`verify_update_set_artifact`].
// [impl->REQ-UPD-6]
pub fn verify_update_set_metadata(
    signed: &SignedUpdateSet,
    policy: &VerifyPolicy,
) -> Result<UpdateSetMetadata, RejectReason> {
    let meta = parse_update_set_meta(signed)?;
    if policy.revoked_keys.contains(&meta.key_id) {
        return Err(RejectReason::RevokedKey(meta.key_id));
    }
    let key = policy
        .trusted_keys
        .get(&meta.key_id)
        .ok_or_else(|| RejectReason::UnknownKey(meta.key_id.clone()))?;
    check_signature(&signed.metadata_json, &signed.signature_hex, key)?;
    if meta.channel != policy.pinned_channel {
        return Err(RejectReason::ChannelMismatch {
            pinned: policy.pinned_channel.clone(),
            got: meta.channel,
        });
    }
    if policy.now_ms > meta.expires_at_ms {
        return Err(RejectReason::Expired {
            now_ms: policy.now_ms,
            expires_at_ms: meta.expires_at_ms,
        });
    }
    if meta.version <= policy.current_version {
        return Err(RejectReason::Rollback {
            current: policy.current_version,
            candidate: meta.version,
        });
    }
    Ok(meta)
}

/// Verify a release's **signature only** against an explicitly-supplied key,
/// returning the authenticated metadata. No trust-set lookup, no
/// channel/expiry/rollback policy — used for **adapter content signing** (C3),
/// where the trusted key is the per-adapter key declared in the adapter's
/// manifest `[update]` rather than spt-core's release-key set, and the adapter's
/// payload only needs authenticity + integrity (paired with [`verify_artifact`]).
// [impl->REQ-UPD-5]
pub fn verify_signature(
    signed: &SignedRelease,
    key: &VerifyingKey,
) -> Result<ReleaseMetadata, RejectReason> {
    let meta = parse_meta(signed)?;
    check_signature(&signed.metadata_json, &signed.signature_hex, key)?;
    Ok(meta)
}

/// Parse the signed metadata bytes into a [`ReleaseMetadata`].
fn parse_meta(signed: &SignedRelease) -> Result<ReleaseMetadata, RejectReason> {
    serde_json::from_str(&signed.metadata_json).map_err(|e| RejectReason::Malformed(e.to_string()))
}

fn parse_update_set_meta(signed: &SignedUpdateSet) -> Result<UpdateSetMetadata, RejectReason> {
    serde_json::from_str(&signed.metadata_json).map_err(|e| RejectReason::Malformed(e.to_string()))
}

/// Verify the Ed25519 signature over the exact signed metadata bytes with `key`.
fn check_signature(
    metadata_json: &str,
    signature_hex: &str,
    key: &VerifyingKey,
) -> Result<(), RejectReason> {
    let sig_bytes = hex_decode(signature_hex).map_err(RejectReason::Malformed)?;
    let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
        RejectReason::Malformed(format!("signature is {} bytes, want 64", sig_bytes.len()))
    })?;
    let signature = Signature::from_bytes(&sig_arr);
    key.verify(metadata_json.as_bytes(), &signature)
        .map_err(|_| RejectReason::BadSignature)
}

/// Parse a hex-encoded Ed25519 public key (64 hex chars / 32 bytes) into a
/// [`VerifyingKey`] — the per-adapter key from a manifest `[update]` block (C3).
// [impl->REQ-UPD-5]
pub fn parse_verifying_key(hex: &str) -> Result<VerifyingKey, RejectReason> {
    let bytes = hex_decode(hex).map_err(RejectReason::Malformed)?;
    let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
        RejectReason::Malformed(format!("public key is {} bytes, want 32", bytes.len()))
    })?;
    VerifyingKey::from_bytes(&arr).map_err(|e| RejectReason::Malformed(e.to_string()))
}

/// Confirm the downloaded binary's bytes match the signed `artifact_sha256`
/// ([`REQ-UPD-2`]). Run after [`verify_metadata`] so the signature has already
/// attested to the expected digest — this closes the loop to the real bytes.
// [impl->REQ-UPD-2]
pub fn verify_artifact(meta: &ReleaseMetadata, artifact: &[u8]) -> Result<(), RejectReason> {
    let actual = sha256_hex(artifact);
    if actual == meta.artifact_sha256.to_ascii_lowercase() {
        Ok(())
    } else {
        Err(RejectReason::ArtifactMismatch {
            signed: meta.artifact_sha256.clone(),
            actual,
        })
    }
}

/// Confirm this platform's downloaded bytes match the digest inside a signed
/// update set.
// [impl->REQ-UPD-6]
pub fn verify_update_set_artifact(
    meta: &UpdateSetMetadata,
    platform: &str,
    artifact: &[u8],
) -> Result<(), RejectReason> {
    let entry = meta.artifact_for(platform)?;
    let actual = sha256_hex(artifact);
    if actual == entry.artifact_sha256.to_ascii_lowercase() {
        Ok(())
    } else {
        Err(RejectReason::ArtifactMismatch {
            signed: entry.artifact_sha256.clone(),
            actual,
        })
    }
}

/// Lowercase-hex SHA-256 of `bytes` — the artifact-digest format used in
/// [`ReleaseMetadata::artifact_sha256`].
pub fn sha256_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hex_encode(&hasher.finalize())
}

/// Lowercase-hex encode (kept inline to avoid a `hex` crate edge for one helper).
fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push(HEX[(b >> 4) as usize] as char);
        s.push(HEX[(b & 0xf) as usize] as char);
    }
    s
}

/// Decode lowercase/uppercase hex. Errors (as a message) on odd length or a
/// non-hex digit.
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
    if !s.len().is_multiple_of(2) {
        return Err(format!("hex length {} is odd", s.len()));
    }
    let val = |c: u8| -> Result<u8, String> {
        match c {
            b'0'..=b'9' => Ok(c - b'0'),
            b'a'..=b'f' => Ok(c - b'a' + 10),
            b'A'..=b'F' => Ok(c - b'A' + 10),
            other => Err(format!("non-hex byte {other:#x}")),
        }
    };
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len() / 2);
    for pair in b.chunks_exact(2) {
        out.push((val(pair[0])? << 4) | val(pair[1])?);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::update::BROKER_RESOURCE_ABI;
    use ed25519_dalek::{Signer, SigningKey};

    const KEY_ID: &str = "rel-2026";
    const CHANNEL: &str = "stable";

    /// A deterministic signing key (no RNG — test-stable). Real release tooling
    /// mints one from a CSPRNG; verification is identical either way.
    fn signing_key(seed: u8) -> SigningKey {
        SigningKey::from_bytes(&[seed; 32])
    }

    /// Build + sign a release for `artifact`, returning the wire form.
    fn sign_release(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
        let metadata_json = serde_json::to_string(meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        SignedRelease {
            metadata_json,
            signature_hex: hex_encode(&sig.to_bytes()),
        }
    }

    fn sign_update_set(sk: &SigningKey, meta: &UpdateSetMetadata) -> SignedUpdateSet {
        let metadata_json = serde_json::to_string(meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        SignedUpdateSet {
            metadata_json,
            signature_hex: hex_encode(&sig.to_bytes()),
        }
    }

    fn meta_for(version: u64, artifact: &[u8]) -> ReleaseMetadata {
        ReleaseMetadata {
            version,
            channel: CHANNEL.to_string(),
            expires_at_ms: 10_000,
            key_id: KEY_ID.to_string(),
            artifact_sha256: sha256_hex(artifact),
            brain_ipc_version: 1,
            broker_resource_abi: BROKER_RESOURCE_ABI,
        }
    }

    fn set_meta(version: u64, win: &[u8], linux: &[u8]) -> UpdateSetMetadata {
        UpdateSetMetadata {
            version,
            channel: CHANNEL.to_string(),
            expires_at_ms: 10_000,
            key_id: KEY_ID.to_string(),
            artifacts: BTreeMap::from([
                (
                    "x86_64-pc-windows-msvc".to_string(),
                    UpdateArtifactMetadata {
                        artifact_sha256: sha256_hex(win),
                        brain_ipc_version: 1,
                        broker_resource_abi: BROKER_RESOURCE_ABI,
                        asset_name: Some("spt-x86_64-windows.exe".to_string()),
                    },
                ),
                (
                    "x86_64-unknown-linux-gnu".to_string(),
                    UpdateArtifactMetadata {
                        artifact_sha256: sha256_hex(linux),
                        brain_ipc_version: 1,
                        broker_resource_abi: BROKER_RESOURCE_ABI,
                        asset_name: Some("spt-x86_64-linux".to_string()),
                    },
                ),
            ]),
            provenance: Some(UpdateSetProvenance {
                git_commit: Some("abc123".to_string()),
                git_dirty: Some(true),
                build_host: Some("fastbox".to_string()),
                build_timestamp_ms: Some(42),
            }),
        }
    }

    // [unit->REQ-UPD-2] the production keys file loads: valid keys trusted,
    // bad hex skipped, channel pinned. Since M6-D6 a missing file no longer
    // means an empty set — every binary carries BUILTIN_RELEASE_KEYS (the
    // ADR-0015 anchor), and the file is a pure overlay on top of it.
    #[test]
    fn production_policy_loads_fail_closed() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("release-keys.json");

        // Missing file: exactly the embedded builtin set, default channel.
        let p = VerifyPolicy::load_from(&path, 3, 1_000);
        assert_eq!(p.trusted_keys.len(), BUILTIN_RELEASE_KEYS.len());
        for (key_id, _) in BUILTIN_RELEASE_KEYS {
            assert!(
                p.trusted_keys.contains_key(*key_id),
                "builtin {key_id} trusted"
            );
        }
        assert_eq!(p.pinned_channel, DEFAULT_CHANNEL);
        assert_eq!((p.current_version, p.now_ms), (3, 1_000));

        // A real key + a malformed one + a revocation + a channel pin: the
        // file OVERLAYS the builtin set (adds, never replaces).
        let vk = signing_key(9).verifying_key();
        let json = serde_json::json!({
            "keys": { "good": hex_encode(vk.as_bytes()), "bad": "zz-not-hex" },
            "revoked": ["old-2024"],
            "channel": "beta",
        });
        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
        let p = VerifyPolicy::load_from(&path, 5, 2_000);
        assert_eq!(
            p.trusted_keys.len(),
            BUILTIN_RELEASE_KEYS.len() + 1,
            "bad hex skipped, good key added to the builtin set"
        );
        assert!(p.trusted_keys.contains_key("good"));
        assert!(p.revoked_keys.contains("old-2024"));
        assert_eq!(p.pinned_channel, "beta");

        // Malformed file degrades to the builtin set, never a panic and
        // never a widened set.
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(
            VerifyPolicy::load_from(&path, 0, 0).trusted_keys.len(),
            BUILTIN_RELEASE_KEYS.len()
        );
    }

    fn policy(sk: &SigningKey) -> VerifyPolicy {
        let mut trusted = BTreeMap::new();
        trusted.insert(KEY_ID.to_string(), sk.verifying_key());
        VerifyPolicy {
            trusted_keys: trusted,
            revoked_keys: BTreeSet::new(),
            pinned_channel: CHANNEL.to_string(),
            current_version: 5,
            now_ms: 1_000, // before the 10_000 expiry
        }
    }

    // [unit->REQ-UPD-2] a correctly-signed, newer, in-channel, unexpired release
    // from a trusted key verifies, and its bytes match the signed digest.
    #[test]
    fn valid_release_verifies_and_artifact_matches() {
        let sk = signing_key(7);
        let artifact = b"the new spt binary bytes";
        let signed = sign_release(&sk, &meta_for(6, artifact));

        let meta = verify_metadata(&signed, &policy(&sk)).expect("valid release verifies");
        assert_eq!(meta.version, 6);
        verify_artifact(&meta, artifact).expect("artifact digest matches the signed value");
        assert_eq!(meta.spec().broker_resource_abi, BROKER_RESOURCE_ABI);
    }

    // [unit->REQ-UPD-2] tampering with the signed bytes after signing ⇒ the
    // signature no longer verifies (the metadata is not authentic).
    #[test]
    fn tampered_metadata_fails_signature() {
        let sk = signing_key(7);
        let mut signed = sign_release(&sk, &meta_for(6, b"bin"));
        // Flip the version in the JSON without re-signing.
        signed.metadata_json = signed
            .metadata_json
            .replace("\"version\":6", "\"version\":9");
        assert_eq!(
            verify_metadata(&signed, &policy(&sk)),
            Err(RejectReason::BadSignature)
        );
    }

    // [unit->REQ-UPD-2] a signature from a key the node does not trust ⇒ UnknownKey.
    #[test]
    fn untrusted_key_is_unknown() {
        let real = signing_key(7);
        let attacker = signing_key(9);
        let signed = sign_release(&attacker, &meta_for(6, b"bin"));
        // Policy trusts only `real` under KEY_ID; the attacker signed under the
        // same key_id but with a different key → signature won't match the trusted
        // verifying key. (UnknownKey would fire if the id itself were untrusted.)
        assert_eq!(
            verify_metadata(&signed, &policy(&real)),
            Err(RejectReason::BadSignature)
        );

        // A genuinely unknown key_id is UnknownKey.
        let mut meta = meta_for(6, b"bin");
        meta.key_id = "no-such-key".to_string();
        let signed2 = sign_release(&attacker, &meta);
        assert_eq!(
            verify_metadata(&signed2, &policy(&real)),
            Err(RejectReason::UnknownKey("no-such-key".to_string()))
        );
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a revoked key is rejected even with an
    // otherwise-valid signature.
    #[test]
    fn revoked_key_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"bin"));
        let mut pol = policy(&sk);
        pol.revoked_keys.insert(KEY_ID.to_string());
        assert_eq!(
            verify_metadata(&signed, &pol),
            Err(RejectReason::RevokedKey(KEY_ID.to_string()))
        );
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a version not strictly greater than the
    // running build is a rollback (both equal and lower are rejected).
    #[test]
    fn non_monotonic_version_is_rollback() {
        let sk = signing_key(7);
        let pol = policy(&sk); // current_version = 5
        for candidate in [5u64, 4, 0] {
            let signed = sign_release(&sk, &meta_for(candidate, b"bin"));
            assert_eq!(
                verify_metadata(&signed, &pol),
                Err(RejectReason::Rollback {
                    current: 5,
                    candidate
                }),
                "version {candidate} must be rejected as a rollback"
            );
        }
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] expired signed metadata is rejected even
    // when everything else is valid (replay of an old-but-signed release).
    #[test]
    fn expired_metadata_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"bin")); // expires_at_ms = 10_000
        let mut pol = policy(&sk);
        pol.now_ms = 20_000; // past expiry
        assert_eq!(
            verify_metadata(&signed, &pol),
            Err(RejectReason::Expired {
                now_ms: 20_000,
                expires_at_ms: 10_000
            })
        );
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a release for another channel is rejected
    // (channel pinning: a beta build cannot land on a stable node).
    #[test]
    fn channel_mismatch_is_rejected() {
        let sk = signing_key(7);
        let mut meta = meta_for(6, b"bin");
        meta.channel = "beta".to_string();
        let signed = sign_release(&sk, &meta);
        assert_eq!(
            verify_metadata(&signed, &policy(&sk)),
            Err(RejectReason::ChannelMismatch {
                pinned: CHANNEL.to_string(),
                got: "beta".to_string()
            })
        );
    }

    // [unit->REQ-UPD-2] a binary whose bytes do not hash to the signed digest is
    // rejected — the signature attests to specific bytes, not just a manifest.
    #[test]
    fn artifact_digest_mismatch_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"the signed bytes"));
        let meta = verify_metadata(&signed, &policy(&sk)).expect("metadata verifies");
        // A different binary than the one whose digest was signed.
        let tampered = b"DIFFERENT bytes than were signed";
        assert!(matches!(
            verify_artifact(&meta, tampered),
            Err(RejectReason::ArtifactMismatch { .. })
        ));
    }

    // [unit->REQ-UPD-6] a platform-targeted update set is signed as one
    // metadata document, then each recipient selects/verifies only its own
    // Rust-target-triple artifact.
    #[test]
    fn update_set_verifies_and_selects_platform_artifact() {
        let sk = signing_key(7);
        let signed = sign_update_set(&sk, &set_meta(6, b"win bytes", b"linux bytes"));
        let meta =
            verify_update_set_metadata(&signed, &policy(&sk)).expect("set metadata verifies");

        let linux = meta.artifact_for("x86_64-unknown-linux-gnu").unwrap();
        assert_eq!(linux.asset_name.as_deref(), Some("spt-x86_64-linux"));
        verify_update_set_artifact(&meta, "x86_64-unknown-linux-gnu", b"linux bytes")
            .expect("linux artifact digest matches");
        verify_update_set_artifact(&meta, "x86_64-pc-windows-msvc", b"win bytes")
            .expect("windows artifact digest matches");
        assert_eq!(
            meta.provenance.as_ref().unwrap().build_host.as_deref(),
            Some("fastbox")
        );
    }

    // [unit->REQ-UPD-6] missing local-platform artifacts are loud typed
    // rejections, not "up to date" no-ops.
    #[test]
    fn update_set_missing_platform_is_typed_rejection() {
        let sk = signing_key(7);
        let mut meta = set_meta(6, b"win bytes", b"linux bytes");
        meta.artifacts.remove("x86_64-pc-windows-msvc");
        let signed = sign_update_set(&sk, &meta);
        let meta = verify_update_set_metadata(&signed, &policy(&sk)).unwrap();
        assert_eq!(
            verify_update_set_artifact(&meta, "x86_64-pc-windows-msvc", b"win bytes"),
            Err(RejectReason::NoArtifactForPlatform {
                platform: "x86_64-pc-windows-msvc".to_string()
            })
        );
    }

    // [unit->REQ-UPD-6] the whole set is signed: modifying any platform digest
    // after signing invalidates the single set signature.
    #[test]
    fn update_set_tampering_breaks_whole_set_signature() {
        let sk = signing_key(7);
        let mut signed = sign_update_set(&sk, &set_meta(6, b"win bytes", b"linux bytes"));
        signed.metadata_json = signed
            .metadata_json
            .replace(&sha256_hex(b"linux bytes"), &sha256_hex(b"evil bytes"));
        assert_eq!(
            verify_update_set_metadata(&signed, &policy(&sk)),
            Err(RejectReason::BadSignature)
        );
    }

    // [unit->REQ-UPD-6] update sets still use channel pinning + monotonic
    // counters, scoped to the policy's pinned channel.
    #[test]
    fn update_set_channel_and_rollback_gates_hold() {
        let sk = signing_key(7);
        let mut wrong_channel = set_meta(6, b"win", b"linux");
        wrong_channel.channel = "debug".to_string();
        let signed = sign_update_set(&sk, &wrong_channel);
        assert_eq!(
            verify_update_set_metadata(&signed, &policy(&sk)),
            Err(RejectReason::ChannelMismatch {
                pinned: CHANNEL.to_string(),
                got: "debug".to_string()
            })
        );

        let rollback = sign_update_set(&sk, &set_meta(5, b"win", b"linux"));
        assert_eq!(
            verify_update_set_metadata(&rollback, &policy(&sk)),
            Err(RejectReason::Rollback {
                current: 5,
                candidate: 5
            })
        );
    }

    // [unit->REQ-UPD-6] a bytes/digest mismatch on the selected platform is
    // rejected even though the set metadata itself verifies.
    #[test]
    fn update_set_artifact_mismatch_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_update_set(&sk, &set_meta(6, b"win bytes", b"linux bytes"));
        let meta = verify_update_set_metadata(&signed, &policy(&sk)).unwrap();
        assert!(matches!(
            verify_update_set_artifact(&meta, "x86_64-unknown-linux-gnu", b"wrong"),
            Err(RejectReason::ArtifactMismatch { .. })
        ));
    }

    // ---- M6-D6: the embedded two-key trust anchor (ADR-0015) ----

    /// Builtin pairs merge under a file overlay: builtin keys trusted with no
    /// file at all; file keys add; file revocations void builtin ids too
    /// (compromise recovery without a rebuild).
    // [unit->REQ-REL-3]
    #[test]
    fn builtin_keys_merge_and_revoke_under_file_overlay() {
        let primary = signing_key(31);
        let recovery = signing_key(32);
        let p_hex = hex_encode(primary.verifying_key().as_bytes());
        let r_hex = hex_encode(recovery.verifying_key().as_bytes());
        let builtin: Vec<(&str, &str)> = vec![
            ("rel-primary", p_hex.as_str()),
            ("rel-recovery", r_hex.as_str()),
        ];

        // No file: exactly the builtin set, default channel.
        let pol = VerifyPolicy::assemble(&builtin, &ReleaseKeysFile::default(), 0, 0);
        assert_eq!(pol.trusted_keys.len(), 2);
        assert!(pol.trusted_keys.contains_key("rel-primary"));
        assert!(pol.trusted_keys.contains_key("rel-recovery"));

        // File overlay: adds a key AND revokes the builtin primary.
        let extra = signing_key(33);
        let file = ReleaseKeysFile {
            keys: BTreeMap::from([(
                "node-extra".to_string(),
                hex_encode(extra.verifying_key().as_bytes()),
            )]),
            revoked: BTreeSet::from(["rel-primary".to_string()]),
            channel: None,
        };
        let pol = VerifyPolicy::assemble(&builtin, &file, 5, 1_000);
        assert_eq!(
            pol.trusted_keys.len(),
            3,
            "file key added to the builtin set"
        );

        // The revoked BUILTIN primary is rejected even with a valid signature…
        let mut meta = meta_for(6, b"bin");
        meta.key_id = "rel-primary".to_string();
        let signed = sign_release(&primary, &meta);
        let mut pol2 = pol.clone();
        pol2.pinned_channel = CHANNEL.to_string();
        assert_eq!(
            verify_metadata(&signed, &pol2),
            Err(RejectReason::RevokedKey("rel-primary".to_string()))
        );

        // …while the same release signed by the RECOVERY key verifies: the
        // ADR-0015 loss/compromise story — the second already-trusted key
        // keeps the update chain alive.
        let mut meta_r = meta_for(6, b"bin");
        meta_r.key_id = "rel-recovery".to_string();
        let signed_r = sign_release(&recovery, &meta_r);
        let got = verify_metadata(&signed_r, &pol2).expect("recovery-signed release verifies");
        assert_eq!(got.key_id, "rel-recovery");
    }

    /// The shipped builtin table itself parses clean: every entry must be a
    /// valid (id, 64-hex Ed25519 key) pair — a typo at the ceremony fails the
    /// build's tests, not a node in the field.
    // [unit->REQ-REL-3]
    #[test]
    fn shipped_builtin_table_is_well_formed() {
        for (key_id, hex) in BUILTIN_RELEASE_KEYS {
            parse_verifying_key(hex)
                .unwrap_or_else(|e| panic!("builtin release key {key_id:?} invalid: {e}"));
            assert!(!key_id.trim().is_empty());
        }
    }

    #[test]
    fn hex_roundtrips() {
        let bytes = [0u8, 1, 15, 16, 255, 128, 7];
        assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
        assert!(hex_decode("abc").is_err()); // odd length
        assert!(hex_decode("zz").is_err()); // non-hex
    }
}


Now read update.rs, applyhost.rs, the relcache, fetch cmd, and xtask publish in parallel.