Skip to main content

cellos_core/
trust_keys.rs

1//! Operator-managed trust-keyset verifying-keys file (SEC-25 Phase 2).
2//!
3//! W2 SEC-25 Phase 1 shipped the dataplane verifier
4//! [`crate::verify_signed_trust_keyset_envelope`] which accepts a
5//! `HashMap<String, TrustAnchorPublicKey>` keyring and an envelope. This
6//! module is the **operator-side keyring loader** that turns the JSON file
7//! described in `docs/trust-plane-runtime.md` § Signed keyset envelopes into
8//! that map.
9//!
10//! Phase 2 wires this into the supervisor (see `cellos-supervisor::trust_keyset_load`)
11//! behind `CELLOS_TRUST_VERIFY_KEYS_PATH`. Sibling consumers
12//! (`cellos-trustd`, taudit, etc.) can also call into [`parse_trust_verify_keys`]
13//! / [`load_trust_verify_keys_file`] directly to avoid re-implementing the
14//! file format.
15//!
16//! ## File format
17//!
18//! Top-level JSON object whose keys are signer kids and whose values are the
19//! base64url encoding of the raw 32-byte Ed25519 public key (no padding,
20//! though padding is tolerated):
21//!
22//! ```json
23//! {
24//!   "ops-envelope-2026-q2": "kE3...base64url-32-bytes...",
25//!   "ops-envelope-2026-q3": "vQp...base64url-32-bytes..."
26//! }
27//! ```
28//!
29//! Duplicate kids are rejected (JSON parsers vary in their dedup behavior;
30//! `serde_json` collapses by default — we do not silently accept that).
31//!
32//! ## Symlink hardening
33//!
34//! [`load_trust_verify_keys_file`] opens the file with `O_NOFOLLOW` on Unix
35//! (matching the SEC-15b protection applied to `CELLOS_POLICY_PACK_PATH` and
36//! `CELLOS_AUTHORITY_KEYS_PATH`) so a swapped-in symlink at the final path
37//! component cannot redirect verifying-key loading to an attacker-controlled
38//! file.
39
40use std::collections::HashMap;
41use std::path::Path;
42
43use base64::engine::general_purpose::URL_SAFE_NO_PAD;
44use base64::Engine as _;
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47
48use crate::crypto::{provider, TrustAnchorPublicKey};
49use crate::error::CellosError;
50use crate::types::CloudEventV1;
51
52/// Parse the verifying-keys JSON document into a `kid → TrustAnchorPublicKey` map.
53///
54/// The expected shape is a top-level JSON object (`{ "<kid>": "<base64url-pubkey>", ... }`).
55/// Each value MUST decode under base64url to exactly 32 bytes (Ed25519 raw
56/// public key length). Padding is tolerated to be friendly to publishers that
57/// emit padded base64url.
58///
59/// # Errors
60///
61/// Returns [`CellosError::InvalidSpec`] when:
62/// - the input is not valid JSON;
63/// - the top-level value is not a JSON object;
64/// - any value is not a string;
65/// - any value fails base64url decode;
66/// - any decoded value is not 32 bytes;
67/// - the JSON parser surfaces a duplicate kid (defense in depth — `serde_json`
68///   normally collapses duplicates).
69///
70/// An empty object is accepted (returns an empty map). The supervisor uses
71/// that as the "no operator keyring configured" path: envelope verification
72/// will then fail with `no signature verified` for any envelope whose signer
73/// kid is not in the empty keyring, which is the intended behaviour.
74pub fn parse_trust_verify_keys(
75    raw: &str,
76) -> Result<HashMap<String, TrustAnchorPublicKey>, CellosError> {
77    let value: Value = serde_json::from_str(raw).map_err(|e| {
78        CellosError::InvalidSpec(format!("trust verify keys: JSON parse error: {e}"))
79    })?;
80
81    let object = value.as_object().ok_or_else(|| {
82        CellosError::InvalidSpec(
83            "trust verify keys: top-level value must be a JSON object mapping kid -> base64url-pubkey".into(),
84        )
85    })?;
86
87    // Defence in depth against parser-side duplicate-kid collapse: the JSON
88    // text is re-scanned to count each kid. `serde_json` collapses duplicate
89    // keys silently in `to_value`, so we walk the raw text via a streaming
90    // pass below before deferring to the parsed object for value extraction.
91    detect_duplicate_keys(raw)?;
92
93    let mut keys: HashMap<String, TrustAnchorPublicKey> = HashMap::with_capacity(object.len());
94    for (kid, value) in object {
95        let pubkey_b64 = value.as_str().ok_or_else(|| {
96            CellosError::InvalidSpec(format!(
97                "trust verify keys: value for kid {kid:?} must be a base64url string, got {value}"
98            ))
99        })?;
100
101        // Tolerate padded or unpadded base64url.
102        let trimmed = pubkey_b64.trim_end_matches('=');
103        let bytes = URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
104            CellosError::InvalidSpec(format!(
105                "trust verify keys: kid {kid:?} value is not valid base64url: {e}"
106            ))
107        })?;
108
109        let array: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
110            CellosError::InvalidSpec(format!(
111                "trust verify keys: kid {kid:?} decoded to {} bytes, expected 32",
112                bytes.len()
113            ))
114        })?;
115
116        // Preserve the historical load-time canonical-point check (formerly
117        // `VerifyingKey::from_bytes`) — a malformed key still fails at load,
118        // not deferred to verify. The dalek check is contained in `crypto/`.
119        let public_key = TrustAnchorPublicKey::from_validated_bytes(array).map_err(|e| {
120            CellosError::InvalidSpec(format!(
121                "trust verify keys: kid {kid:?} is not a valid Ed25519 verifying key: {e}"
122            ))
123        })?;
124
125        keys.insert(kid.clone(), public_key);
126    }
127
128    Ok(keys)
129}
130
131/// Read [`parse_trust_verify_keys`]'s input from a path and decode it.
132///
133/// On Unix this opens with `O_NOFOLLOW` (matching `CELLOS_POLICY_PACK_PATH` /
134/// `CELLOS_AUTHORITY_KEYS_PATH` policy in `composition.rs` — SEC-15b) so the
135/// final path component cannot be a symlink redirected at an
136/// attacker-controlled file. Windows has no `O_NOFOLLOW` analogue in the std
137/// API, so [`reject_reparse_point`] performs a best-effort pre-read check that
138/// refuses a final-component reparse point (symlink/junction) — a weaker,
139/// check-then-open guard with a declared TOCTOU residual (S37, ADR-0031).
140///
141/// # Errors
142///
143/// Returns [`CellosError::InvalidSpec`] when the file cannot be opened, read,
144/// or decoded as UTF-8, plus every error class from [`parse_trust_verify_keys`].
145pub fn load_trust_verify_keys_file(
146    path: &Path,
147) -> Result<HashMap<String, TrustAnchorPublicKey>, CellosError> {
148    let raw = read_trust_file_to_string(path)?;
149    parse_trust_verify_keys(&raw)
150}
151
152/// Read a trust file to a string with the symlink-swap defenses every trust-file
153/// load shares: `O_NOFOLLOW` on Unix (the final path component cannot be a
154/// symlink) and a best-effort reparse-point refusal on Windows (S37). Used by
155/// both [`load_trust_verify_keys_file`] and [`load_revocation_list_file`] so the
156/// at-rest protections — and the O_NOFOLLOW per-platform constants — live in one
157/// place.
158fn read_trust_file_to_string(path: &Path) -> Result<String, CellosError> {
159    #[cfg(unix)]
160    {
161        use std::io::Read;
162        use std::os::unix::fs::OpenOptionsExt;
163        let mut opts = std::fs::OpenOptions::new();
164        opts.read(true);
165        // O_NOFOLLOW value is platform-specific. cellos-core deliberately
166        // avoids a `libc` dependency, so we hard-code the kernel ABI values
167        // for the runtime targets we care about. Adding a new Unix variant
168        // here is a one-line change, not a libc-crate refactor.
169        //   - Linux: octal 0o400000 == 0x20000  (asm-generic/fcntl.h)
170        //   - macOS / *BSD:           == 0x100  (sys/fcntl.h)
171        // Using the wrong constant silently maps to a different flag (on
172        // Linux 0x100 is `O_NOCTTY`, which would *not* refuse a symlink),
173        // so this MUST stay accurate per platform.
174        #[cfg(target_os = "linux")]
175        const O_NOFOLLOW: i32 = 0x20000;
176        #[cfg(any(
177            target_os = "macos",
178            target_os = "ios",
179            target_os = "freebsd",
180            target_os = "netbsd",
181            target_os = "openbsd",
182            target_os = "dragonfly",
183        ))]
184        const O_NOFOLLOW: i32 = 0x100;
185        // Build break here on a new Unix is intentional: pick the right
186        // constant from the platform's <fcntl.h> rather than guessing.
187        #[cfg(not(any(
188            target_os = "linux",
189            target_os = "macos",
190            target_os = "ios",
191            target_os = "freebsd",
192            target_os = "netbsd",
193            target_os = "openbsd",
194            target_os = "dragonfly",
195        )))]
196        compile_error!(
197            "cellos-core::trust_keys: O_NOFOLLOW value not yet defined for this Unix target — \
198             add the platform-specific value (see <fcntl.h>) before building."
199        );
200        opts.custom_flags(O_NOFOLLOW);
201        let mut file = opts.open(path).map_err(|e| {
202            CellosError::InvalidSpec(format!("trust file: cannot open {}: {e}", path.display()))
203        })?;
204        let mut buf = String::new();
205        file.read_to_string(&mut buf).map_err(|e| {
206            CellosError::InvalidSpec(format!("trust file: cannot read {}: {e}", path.display()))
207        })?;
208        Ok(buf)
209    }
210    #[cfg(not(unix))]
211    {
212        #[cfg(windows)]
213        reject_reparse_point(path)?;
214        std::fs::read_to_string(path).map_err(|e| {
215            CellosError::InvalidSpec(format!("trust file: cannot read {}: {e}", path.display()))
216        })
217    }
218}
219
220/// Load + verify a signed revocation list from `path` (S35, ADR-0031).
221///
222/// Reads the file with the same `O_NOFOLLOW` / reparse-point defenses as the
223/// trust-verify keys (S37), parses the
224/// [`crate::types::SignedTrustKeysetEnvelope`], and returns the revoked-kid set
225/// via [`verify_and_parse_revocation_envelope`] (verified under `verifying_keys`,
226/// media type re-asserted, inner list parsed). Fail-closed: any read / parse /
227/// verify failure returns `Err` and yields no revocations — a caller under a
228/// hardened profile MUST propagate that error rather than proceed unrevoked.
229pub fn load_revocation_list_file(
230    path: &Path,
231    verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
232    now: std::time::SystemTime,
233) -> Result<std::collections::HashSet<String>, CellosError> {
234    let raw = read_trust_file_to_string(path)?;
235    let envelope: crate::types::SignedTrustKeysetEnvelope =
236        serde_json::from_str(&raw).map_err(|e| {
237            CellosError::InvalidSpec(format!(
238                "revocation list: {} is not a SignedTrustKeysetEnvelope: {e}",
239                path.display()
240            ))
241        })?;
242    verify_and_parse_revocation_envelope(&envelope, verifying_keys, now)
243}
244
245/// Best-effort Windows defense against a final-path-component reparse point
246/// (symlink, junction, mount point) swapped in to redirect a trust-file read at
247/// an attacker-controlled target. Closes the bare-`read_to_string` gap on
248/// Windows, which has no `O_NOFOLLOW` analogue in the std API (S37).
249///
250/// **Residual (declared; key-lifecycle STIG row, ADR-0031 / S40).** This is a
251/// check-then-open guard: there is a TOCTOU window between this
252/// `symlink_metadata` check and the subsequent open/read, so it is NOT an
253/// `O_NOFOLLOW`-equivalent atomic control — only best-effort hardening. The
254/// owner/ACL portion of the gap (file must be SYSTEM/Administrators-owned, not
255/// world-writable) is not yet enforced here and is tracked as the same residual.
256#[cfg(windows)]
257pub fn reject_reparse_point(path: &std::path::Path) -> Result<(), CellosError> {
258    use std::os::windows::fs::MetadataExt;
259    // FILE_ATTRIBUTE_REPARSE_POINT — covers symlinks, junctions, mount points.
260    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
261    let meta = std::fs::symlink_metadata(path).map_err(|e| {
262        CellosError::InvalidSpec(format!("trust file: cannot stat {}: {e}", path.display()))
263    })?;
264    if meta.file_type().is_symlink() || (meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0
265    {
266        return Err(CellosError::InvalidSpec(format!(
267            "trust file: refusing {}: final path component is a reparse point \
268             (symlink/junction); best-effort Windows symlink-swap defense (TOCTOU residual)",
269            path.display()
270        )));
271    }
272    Ok(())
273}
274
275/// Single-pass duplicate-key detector for the top-level JSON object.
276///
277/// `serde_json`'s `Value` collapses duplicate object keys with last-write-wins
278/// semantics. For a verifying-keys file that's a silent footgun: an attacker
279/// who can inject a second copy of an existing kid with a different pubkey
280/// would silently substitute the verifier's key. This walker scans the raw
281/// JSON text for top-level object string keys and rejects the file if any
282/// kid appears twice.
283///
284/// The walker is deliberately simple — it tracks string state and a single
285/// nesting depth so it only counts keys at the outermost object — and does
286/// not attempt to fully reparse JSON. It is robust against escaped quotes,
287/// nested objects, arrays, and whitespace; if the structure is malformed in
288/// a way the walker can't reason about, it falls through and lets
289/// `serde_json::from_str` (called by the caller) surface the parse error.
290fn detect_duplicate_keys(raw: &str) -> Result<(), CellosError> {
291    use std::collections::HashSet;
292
293    let bytes = raw.as_bytes();
294    let mut seen: HashSet<String> = HashSet::new();
295    let mut idx = 0;
296    let mut depth: i32 = 0;
297    let mut in_string = false;
298    let mut after_colon_in_outer = false;
299    let mut current_key: Option<String> = None;
300    let mut escape = false;
301    let mut started = false;
302
303    while idx < bytes.len() {
304        let b = bytes[idx];
305        if in_string {
306            if escape {
307                escape = false;
308                if let Some(k) = current_key.as_mut() {
309                    k.push(b as char);
310                }
311                idx += 1;
312                continue;
313            }
314            match b {
315                b'\\' => {
316                    escape = true;
317                    if let Some(k) = current_key.as_mut() {
318                        k.push(b as char);
319                    }
320                }
321                b'"' => {
322                    in_string = false;
323                    if depth == 1 && !after_colon_in_outer {
324                        if let Some(key) = current_key.take() {
325                            if !seen.insert(key.clone()) {
326                                return Err(CellosError::InvalidSpec(format!(
327                                    "trust verify keys: duplicate kid {key:?} in keys file"
328                                )));
329                            }
330                        }
331                    } else {
332                        // string was a value, not a key — discard.
333                        let _ = current_key.take();
334                    }
335                }
336                _ => {
337                    if let Some(k) = current_key.as_mut() {
338                        k.push(b as char);
339                    }
340                }
341            }
342            idx += 1;
343            continue;
344        }
345
346        match b {
347            b'"' => {
348                in_string = true;
349                // Only collect strings that could be top-level keys: depth==1
350                // AND we are NOT after a colon (i.e. we expect a key here).
351                if depth == 1 && !after_colon_in_outer {
352                    current_key = Some(String::new());
353                } else {
354                    current_key = Some(String::new()); // placeholder so the
355                                                       // closing quote branch
356                                                       // discards uniformly.
357                }
358            }
359            b'{' => {
360                depth += 1;
361                started = true;
362            }
363            b'}' => {
364                depth -= 1;
365                after_colon_in_outer = false;
366                if depth == 0 {
367                    return Ok(());
368                }
369            }
370            b'[' => {
371                depth += 1;
372            }
373            b']' => {
374                depth -= 1;
375            }
376            b':' if depth == 1 => {
377                after_colon_in_outer = true;
378            }
379            b',' if depth == 1 => {
380                after_colon_in_outer = false;
381            }
382            _ => {}
383        }
384        idx += 1;
385    }
386
387    // Reached end of input without closing the outermost object: let
388    // serde_json surface the structural error. Treat as no-duplicate-detected
389    // here (the parse will fail later regardless).
390    let _ = started;
391    Ok(())
392}
393
394// ── I5: Per-event signing (HMAC-SHA256 / Ed25519) ───────────────────────────
395//
396// Extends the SEC-25 envelope-verification model down to individual
397// CloudEvents so JetStream consumers / projectors can independently verify
398// authorship of a single event without re-walking the keyset envelope.
399//
400// Doctrine: D1 — this is an OPT-IN signing path. Producers that don't sign
401// emit raw `CloudEventV1` envelopes exactly as before; consumers that don't
402// verify see no change.
403//
404// Algorithms:
405//   - "ed25519": producer signs the canonical-JSON serialization with an
406//     Ed25519 signing key; consumer verifies with the matching public key.
407//   - "hmac-sha256": shared symmetric key; FIPS 198 HMAC over the canonical
408//     JSON serialization. Implemented inline over `sha2::Sha256` so we
409//     don't pull a new crate dependency.
410//
411// `notBefore` / `notAfter` mirror the trust-keyset envelope schema and are
412// advisory — this primitive does not enforce them.
413
414/// Per-event signed envelope wrapping a single CloudEvent.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct SignedEventEnvelopeV1 {
418    pub event: CloudEventV1,
419    pub signer_kid: String,
420    pub algorithm: String,
421    pub signature: String,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub not_before: Option<String>,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub not_after: Option<String>,
426}
427
428/// Canonical JSON form of a CloudEvent for signing/verification.
429///
430/// Routes through the versioned sorted-keys canonicalizer
431/// ([`crate::canonical::canonical_payload`], ADR-0028) so the signed byte
432/// stream is a function of the event's *logical* JSON value — independent of
433/// `struct` field-declaration order — and adding an optional CloudEvent
434/// attribute that a given event omits can never shift the bytes signed for it.
435/// Signing and verification both call this function, so swapping the encoder is
436/// a transparent runtime round-trip change.
437pub fn canonical_event_signing_payload(event: &CloudEventV1) -> Result<Vec<u8>, CellosError> {
438    crate::canonical::canonical_payload(event)
439}
440
441/// Sign a CloudEvent with an Ed25519 signing key given as its raw 32-byte seed.
442///
443/// Routes through the active [`crate::crypto::CryptoProvider`] (ADR-0027, S04)
444/// rather than calling `ed25519_dalek` directly, so a FIPS-validated provider
445/// (S05) signs without any change here. The seed is the dalek
446/// `SigningKey::from_bytes` representation.
447pub fn sign_event_ed25519(
448    event: &CloudEventV1,
449    signer_kid: &str,
450    signing_key_seed: &[u8; 32],
451) -> Result<SignedEventEnvelopeV1, CellosError> {
452    let payload = canonical_event_signing_payload(event)?;
453    let signature = provider().sign_ed25519(signing_key_seed, &payload)?;
454    Ok(SignedEventEnvelopeV1 {
455        event: event.clone(),
456        signer_kid: signer_kid.to_string(),
457        algorithm: "ed25519".to_string(),
458        signature: URL_SAFE_NO_PAD.encode(signature),
459        not_before: None,
460        not_after: None,
461    })
462}
463
464/// A signer abstraction (ADR-0031, S32) so a call site need not hold a raw
465/// signing seed: the private key can live behind a [`SoftwareSigner`] (seed in
466/// process memory, the default custody mode) today, or an HSM / PKCS#11 signer
467/// (S34) later, behind the same trait. Signing routes through the active
468/// [`crate::crypto::CryptoProvider`] (ADR-0027), so a FIPS-validated provider is
469/// transparent to holders of a `Signer`.
470pub trait Signer: Send + Sync {
471    /// The signer key id (`kid`) stamped into the envelope's `signerKid`.
472    fn kid(&self) -> &str;
473
474    /// Sign `message`, returning the raw 64-byte Ed25519 signature.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if the underlying provider/module rejects the operation.
479    fn sign(&self, message: &[u8]) -> Result<[u8; 64], CellosError>;
480
481    /// The public verifying key, for building a verifier-side keyring entry.
482    fn verifying_key(&self) -> TrustAnchorPublicKey;
483}
484
485/// In-process Ed25519 signer holding the raw 32-byte seed (the default key
486/// custody mode; HSM-backed custody is S34). The seed is wrapped in
487/// [`zeroize::Zeroizing`] so it is wiped on drop. Behaviour-identical to
488/// [`sign_event_ed25519`] for the same seed + kid — see
489/// [`sign_event_with`].
490pub struct SoftwareSigner {
491    kid: String,
492    seed: zeroize::Zeroizing<[u8; 32]>,
493    public: TrustAnchorPublicKey,
494}
495
496impl SoftwareSigner {
497    /// Build a signer from a raw 32-byte Ed25519 seed and a `kid`. The public
498    /// key is derived once via the active provider's adapter and cached.
499    ///
500    /// # Errors
501    ///
502    /// Returns an error if the seed cannot be turned into a valid keypair.
503    pub fn from_seed(kid: impl Into<String>, seed: [u8; 32]) -> Result<Self, CellosError> {
504        // C07: derive via the active provider so the FIPS-pure build needs no
505        // dalek call here.
506        let public = TrustAnchorPublicKey::from_bytes_unchecked(
507            crate::crypto::provider().public_key_from_seed(&seed)?,
508        );
509        Ok(Self {
510            kid: kid.into(),
511            seed: zeroize::Zeroizing::new(seed),
512            public,
513        })
514    }
515}
516
517impl Signer for SoftwareSigner {
518    fn kid(&self) -> &str {
519        &self.kid
520    }
521
522    fn sign(&self, message: &[u8]) -> Result<[u8; 64], CellosError> {
523        Ok(provider().sign_ed25519(&*self.seed, message)?)
524    }
525
526    fn verifying_key(&self) -> TrustAnchorPublicKey {
527        self.public
528    }
529}
530
531/// Sign a CloudEvent through a [`Signer`] (ADR-0031, S32).
532///
533/// Produces a [`SignedEventEnvelopeV1`] byte-identical to
534/// [`sign_event_ed25519`] when `signer` is a [`SoftwareSigner`] built from the
535/// same seed + kid — it is the same canonical payload, the same provider
536/// signature, and the same envelope shape, just with the key held behind the
537/// `Signer` seam instead of passed as raw bytes.
538pub fn sign_event_with(
539    signer: &dyn Signer,
540    event: &CloudEventV1,
541) -> Result<SignedEventEnvelopeV1, CellosError> {
542    let payload = canonical_event_signing_payload(event)?;
543    let signature = signer.sign(&payload)?;
544    Ok(SignedEventEnvelopeV1 {
545        event: event.clone(),
546        signer_kid: signer.kid().to_string(),
547        algorithm: "ed25519".to_string(),
548        signature: URL_SAFE_NO_PAD.encode(signature),
549        not_before: None,
550        not_after: None,
551    })
552}
553
554/// Sign a CloudEvent with HMAC-SHA256 (FIPS 198).
555pub fn sign_event_hmac_sha256(
556    event: &CloudEventV1,
557    signer_kid: &str,
558    key_bytes: &[u8],
559) -> Result<SignedEventEnvelopeV1, CellosError> {
560    let payload = canonical_event_signing_payload(event)?;
561    let mac = provider().hmac_sha256(key_bytes, &payload);
562    Ok(SignedEventEnvelopeV1 {
563        event: event.clone(),
564        signer_kid: signer_kid.to_string(),
565        algorithm: "hmac-sha256".to_string(),
566        signature: URL_SAFE_NO_PAD.encode(mac),
567        not_before: None,
568        not_after: None,
569    })
570}
571
572/// Verify a [`SignedEventEnvelopeV1`], additionally rejecting a revoked signer
573/// kid (S35, ADR-0031). A `signerKid` present in `revoked_kids` is rejected with
574/// a `signer_kid_revoked` error **even when its signature is otherwise valid and
575/// inside its `notAfter` window** — revocation takes precedence over expiry. With
576/// an empty `revoked_kids` this is identical to [`verify_signed_event_envelope`].
577pub fn verify_signed_event_envelope_with_revocations<'a>(
578    envelope: &'a SignedEventEnvelopeV1,
579    verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
580    hmac_keys: &HashMap<String, Vec<u8>>,
581    revoked_kids: &std::collections::HashSet<String>,
582) -> Result<&'a CloudEventV1, CellosError> {
583    if revoked_kids.contains(&envelope.signer_kid) {
584        return Err(CellosError::InvalidSpec(format!(
585            "signed event envelope: signer_kid_revoked: {:?}",
586            envelope.signer_kid
587        )));
588    }
589    verify_signed_event_envelope(envelope, verifying_keys, hmac_keys)
590}
591
592/// Verify a [`crate::types::SignedTrustKeysetEnvelope`] carrying a
593/// [`crate::types::RevocationListV1`] and return the set of revoked signer kids
594/// (S35, ADR-0031).
595///
596/// Fail-closed: the envelope MUST verify under `verifying_keys` (org-root) via
597/// the SEC-25 envelope verifier, its `payloadType` MUST be
598/// [`crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE`] (re-asserted after the
599/// payload-agnostic verify, the same posture the ceiling loader uses), and the
600/// inner payload MUST parse as a `RevocationListV1`. Any failure returns `Err`
601/// and yields no revocations.
602pub fn verify_and_parse_revocation_envelope(
603    envelope: &crate::types::SignedTrustKeysetEnvelope,
604    verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
605    now: std::time::SystemTime,
606) -> Result<std::collections::HashSet<String>, CellosError> {
607    let payload_bytes =
608        crate::spec_validation::verify_signed_trust_keyset_envelope(envelope, verifying_keys, now)?;
609    if envelope.payload_type != crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE {
610        return Err(CellosError::InvalidSpec(format!(
611            "revocation list: expected payloadType {}, got '{}'",
612            crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE,
613            envelope.payload_type
614        )));
615    }
616    let list: crate::types::RevocationListV1 =
617        serde_json::from_slice(&payload_bytes).map_err(|e| {
618            CellosError::InvalidSpec(format!(
619                "revocation list: payload is not a RevocationListV1: {e}"
620            ))
621        })?;
622    Ok(list.revocations.into_iter().map(|r| r.kid).collect())
623}
624
625/// Verify a [`SignedEventEnvelopeV1`] against a verifier-side keyring.
626pub fn verify_signed_event_envelope<'a>(
627    envelope: &'a SignedEventEnvelopeV1,
628    verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
629    hmac_keys: &HashMap<String, Vec<u8>>,
630) -> Result<&'a CloudEventV1, CellosError> {
631    let payload = canonical_event_signing_payload(&envelope.event)?;
632    let sig_b64 = envelope.signature.trim_end_matches('=');
633    let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|e| {
634        CellosError::InvalidSpec(format!(
635            "signed event envelope: signature is not valid base64url: {e}"
636        ))
637    })?;
638
639    match envelope.algorithm.as_str() {
640        "ed25519" => {
641            let verifying_key = verifying_keys.get(&envelope.signer_kid).ok_or_else(|| {
642                CellosError::InvalidSpec(format!(
643                    "signed event envelope: unknown ed25519 signer kid {:?}",
644                    envelope.signer_kid
645                ))
646            })?;
647            if sig_bytes.len() != 64 {
648                return Err(CellosError::InvalidSpec(format!(
649                    "signed event envelope: ed25519 signature must be 64 bytes, got {}",
650                    sig_bytes.len()
651                )));
652            }
653            provider()
654                .verify_ed25519(verifying_key.as_bytes(), &payload, &sig_bytes)
655                .map_err(|e| {
656                    CellosError::InvalidSpec(format!(
657                        "signed event envelope: ed25519 verify failed: {e}"
658                    ))
659                })?;
660            Ok(&envelope.event)
661        }
662        "hmac-sha256" => {
663            let key = hmac_keys.get(&envelope.signer_kid).ok_or_else(|| {
664                CellosError::InvalidSpec(format!(
665                    "signed event envelope: unknown hmac-sha256 signer kid {:?}",
666                    envelope.signer_kid
667                ))
668            })?;
669            if sig_bytes.len() != 32 {
670                return Err(CellosError::InvalidSpec(format!(
671                    "signed event envelope: hmac-sha256 mac must be 32 bytes, got {}",
672                    sig_bytes.len()
673                )));
674            }
675            let expected = provider().hmac_sha256(key, &payload);
676            if !provider().constant_time_eq(&expected, &sig_bytes) {
677                return Err(CellosError::InvalidSpec(
678                    "signed event envelope: hmac-sha256 verify failed".into(),
679                ));
680            }
681            Ok(&envelope.event)
682        }
683        other => Err(CellosError::InvalidSpec(format!(
684            "signed event envelope: unknown algorithm {other:?} (expected ed25519 or hmac-sha256)"
685        ))),
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::{load_trust_verify_keys_file, parse_trust_verify_keys};
692    use crate::crypto::{provider, TrustAnchorPublicKey};
693    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
694    use base64::Engine as _;
695    use std::io::Write;
696
697    /// Raw 32-byte Ed25519 seed for a deterministic test key.
698    fn seed_bytes(seed: u8) -> [u8; 32] {
699        [seed; 32]
700    }
701
702    /// The matching trust-anchor public key for a test seed.
703    fn anchor(seed: u8) -> TrustAnchorPublicKey {
704        TrustAnchorPublicKey::from_bytes_unchecked(
705            provider()
706                .public_key_from_seed(&seed_bytes(seed))
707                .expect("derive pub"),
708        )
709    }
710
711    fn pubkey_b64(seed: u8) -> String {
712        URL_SAFE_NO_PAD.encode(
713            provider()
714                .public_key_from_seed(&seed_bytes(seed))
715                .expect("derive pub"),
716        )
717    }
718
719    #[test]
720    fn parses_well_formed_two_key_map() {
721        let raw = format!(
722            r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q3": "{}" }}"#,
723            pubkey_b64(7),
724            pubkey_b64(11)
725        );
726        let keys = parse_trust_verify_keys(&raw).expect("well-formed map must parse");
727        assert_eq!(keys.len(), 2);
728        assert!(keys.contains_key("ops-envelope-2026-q2"));
729        assert!(keys.contains_key("ops-envelope-2026-q3"));
730        assert_eq!(
731            keys["ops-envelope-2026-q2"],
732            anchor(7),
733            "kid q2 must round-trip to its source verifying key"
734        );
735    }
736
737    #[test]
738    fn rejects_duplicate_kid() {
739        let raw = format!(
740            r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q2": "{}" }}"#,
741            pubkey_b64(7),
742            pubkey_b64(11)
743        );
744        let err = parse_trust_verify_keys(&raw).expect_err("duplicate kid must be rejected");
745        let msg = format!("{err}");
746        assert!(
747            msg.contains("duplicate kid"),
748            "expected duplicate-kid error, got: {msg}"
749        );
750    }
751
752    #[test]
753    fn rejects_malformed_base64() {
754        let raw = r#"{ "ops-bad": "@@@not-base64@@@" }"#;
755        let err = parse_trust_verify_keys(raw).expect_err("malformed base64 must be rejected");
756        let msg = format!("{err}");
757        assert!(
758            msg.contains("not valid base64url"),
759            "expected base64-decode error, got: {msg}"
760        );
761    }
762
763    #[test]
764    fn rejects_wrong_length_pubkey() {
765        // 16 bytes of zeros, base64url-encoded — too short for an Ed25519 pubkey.
766        let too_short = URL_SAFE_NO_PAD.encode([0u8; 16]);
767        let raw = format!(r#"{{ "ops-short": "{too_short}" }}"#);
768        let err = parse_trust_verify_keys(&raw).expect_err("16-byte pubkey must be rejected");
769        let msg = format!("{err}");
770        assert!(
771            msg.contains("expected 32"),
772            "expected 32-byte length error, got: {msg}"
773        );
774    }
775
776    #[test]
777    fn empty_object_is_accepted() {
778        let raw = "{}";
779        let keys = parse_trust_verify_keys(raw).expect("empty object is the no-keys case");
780        assert!(keys.is_empty());
781    }
782
783    #[test]
784    fn missing_file_errors() {
785        let path = std::path::Path::new("/nonexistent/path/that/should/not/exist.json");
786        let err =
787            load_trust_verify_keys_file(path).expect_err("missing file must surface an error");
788        let msg = format!("{err}");
789        assert!(
790            msg.contains("cannot") && msg.contains("nonexistent"),
791            "expected file-open error, got: {msg}"
792        );
793    }
794
795    #[test]
796    fn rejects_non_utf8_input() {
797        let dir = tempfile::tempdir().expect("tmpdir");
798        let path = dir.path().join("trust-keys-non-utf8.json");
799        let mut f = std::fs::File::create(&path).expect("create");
800        // Bytes that are NOT valid UTF-8.
801        f.write_all(&[0xFF, 0xFE, 0xFD, 0xFC]).expect("write");
802        drop(f);
803        let err = load_trust_verify_keys_file(&path).expect_err("non-utf8 must error");
804        let msg = format!("{err}");
805        // On Unix this surfaces from `read_to_string`'s utf8 check.
806        assert!(
807            msg.contains("cannot read") || msg.contains("utf-8") || msg.contains("UTF-8"),
808            "expected non-utf8 read error, got: {msg}"
809        );
810    }
811
812    #[test]
813    fn rejects_top_level_non_object() {
814        let raw = r#"["not", "an", "object"]"#;
815        let err = parse_trust_verify_keys(raw).expect_err("top-level non-object must be rejected");
816        let msg = format!("{err}");
817        assert!(
818            msg.contains("must be a JSON object"),
819            "expected top-level-object error, got: {msg}"
820        );
821    }
822
823    #[test]
824    fn loads_valid_file_via_load_helper() {
825        // Round-trip the file path: write a well-formed two-key map and load
826        // it back via the on-disk helper, exercising the O_NOFOLLOW path on
827        // Unix.
828        let dir = tempfile::tempdir().expect("tmpdir");
829        let path = dir.path().join("trust-keys.json");
830        let raw = format!(
831            r#"{{ "kid-active-7": "{}", "kid-active-11": "{}" }}"#,
832            pubkey_b64(7),
833            pubkey_b64(11)
834        );
835        std::fs::write(&path, raw).expect("write keys");
836        let keys = load_trust_verify_keys_file(&path).expect("load via helper");
837        assert_eq!(keys.len(), 2);
838        assert_eq!(keys["kid-active-7"], anchor(7));
839    }
840
841    /// S37: on Windows the loader refuses a final-component reparse point
842    /// (symlink/junction) before reading — best-effort symlink-swap defense
843    /// (TOCTOU residual). Symlink creation needs privilege / Developer Mode, so
844    /// the test skips with a note when it cannot create one.
845    #[cfg(windows)]
846    #[test]
847    fn load_helper_rejects_reparse_point_at_final_component_windows() {
848        let dir = tempfile::tempdir().expect("tmpdir");
849        let real_path = dir.path().join("trust-keys-real.json");
850        let link_path = dir.path().join("trust-keys-link.json");
851        let raw = format!(r#"{{ "kid-only-1": "{}" }}"#, pubkey_b64(7));
852        std::fs::write(&real_path, raw).expect("write real keys file");
853
854        // The real file loads.
855        load_trust_verify_keys_file(&real_path).expect("real path loads");
856
857        if std::os::windows::fs::symlink_file(&real_path, &link_path).is_err() {
858            eprintln!(
859                "load_helper_rejects_reparse_point_at_final_component_windows: skipping — \
860                 cannot create a symlink (no SeCreateSymbolicLinkPrivilege / Developer Mode)"
861            );
862            return;
863        }
864
865        let err = load_trust_verify_keys_file(&link_path)
866            .expect_err("a reparse point at the final component must be rejected");
867        assert!(
868            format!("{err}").contains("reparse point"),
869            "expected reparse-point rejection, got: {err}"
870        );
871    }
872
873    /// Symlink rejection — proves O_NOFOLLOW is the right kernel flag on this
874    /// platform. Without this test we silently shipped `O_NOCTTY` on Linux
875    /// (0x100 is O_NOCTTY there; O_NOFOLLOW is 0x20000) and the loader would
876    /// accept attacker-swappable symlinks. Pin the property so a future rename
877    /// or constant edit can't regress without a failing test.
878    #[cfg(unix)]
879    #[test]
880    fn load_helper_rejects_symlink_at_final_component() {
881        let dir = tempfile::tempdir().expect("tmpdir");
882        let real_path = dir.path().join("trust-keys-real.json");
883        let symlink_path = dir.path().join("trust-keys-symlink.json");
884        let raw = format!(r#"{{ "kid-only-1": "{}" }}"#, pubkey_b64(7));
885        std::fs::write(&real_path, raw).expect("write real keys file");
886        std::os::unix::fs::symlink(&real_path, &symlink_path).expect("create symlink");
887
888        // Sanity: reading the real file works.
889        load_trust_verify_keys_file(&real_path).expect("real path loads");
890
891        // The symlink at the final component MUST be rejected by O_NOFOLLOW.
892        let err = load_trust_verify_keys_file(&symlink_path)
893            .expect_err("symlink at final component must be rejected");
894        let msg = format!("{err}");
895        assert!(
896            msg.contains("cannot open"),
897            "expected open-side rejection, got: {msg}"
898        );
899    }
900
901    // ── I5: per-event signing primitives ───────────────────────────────────
902
903    use super::{
904        canonical_event_signing_payload, sign_event_ed25519, sign_event_hmac_sha256,
905        sign_event_with, verify_signed_event_envelope, Signer, SoftwareSigner,
906    };
907    use crate::types::CloudEventV1;
908    use std::collections::HashMap;
909
910    fn sample_event() -> CloudEventV1 {
911        CloudEventV1 {
912            specversion: "1.0".into(),
913            id: "ev-001".into(),
914            source: "/cellos-supervisor".into(),
915            ty: "dev.cellos.events.cell.lifecycle.v1.started".into(),
916            datacontenttype: Some("application/json".into()),
917            data: Some(serde_json::json!({"cellId": "test-cell-1"})),
918            time: Some("2026-05-06T12:00:00Z".into()),
919            traceparent: None,
920            cex: None,
921        }
922    }
923
924    #[test]
925    fn ed25519_round_trip_verifies() {
926        let seed = seed_bytes(31);
927        let event = sample_event();
928        let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
929
930        assert_eq!(envelope.algorithm, "ed25519");
931        assert_eq!(envelope.signer_kid, "ops-event-2026-q2");
932
933        let mut keys = HashMap::new();
934        keys.insert("ops-event-2026-q2".to_string(), anchor(31));
935        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
936        let verified =
937            verify_signed_event_envelope(&envelope, &keys, &hmac_keys).expect("verify ok");
938        assert_eq!(verified.id, event.id);
939    }
940
941    #[test]
942    fn ed25519_tampered_event_fails_verify() {
943        let seed = seed_bytes(31);
944        let event = sample_event();
945        let mut envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
946        envelope.event.id = "ev-tampered".into();
947
948        let mut keys = HashMap::new();
949        keys.insert("ops-event-2026-q2".to_string(), anchor(31));
950        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
951        let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
952            .expect_err("tampered event must fail verify");
953        assert!(format!("{err}").contains("ed25519 verify failed"));
954    }
955
956    #[test]
957    fn ed25519_unknown_kid_fails_verify() {
958        let seed = seed_bytes(31);
959        let event = sample_event();
960        let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
961        let keys: HashMap<String, TrustAnchorPublicKey> = HashMap::new();
962        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
963        let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
964            .expect_err("unknown kid must fail");
965        assert!(format!("{err}").contains("unknown ed25519 signer kid"));
966    }
967
968    #[test]
969    fn software_signer_matches_sign_event_ed25519_and_verifies() {
970        // S32 (ADR-0031): a SoftwareSigner built from the same seed + kid
971        // produces a byte-identical envelope to the raw-seed
972        // `sign_event_ed25519`, and the result verifies offline against the
973        // signer's own verifying key.
974        let seed = seed_bytes(31);
975        let event = sample_event();
976        let kid = "ops-event-2026-q2";
977
978        let legacy = sign_event_ed25519(&event, kid, &seed).expect("legacy sign");
979        let signer = SoftwareSigner::from_seed(kid, seed).expect("build signer");
980        let via_seam = sign_event_with(&signer, &event).expect("seam sign");
981
982        assert_eq!(
983            serde_json::to_vec(&legacy).unwrap(),
984            serde_json::to_vec(&via_seam).unwrap(),
985            "SoftwareSigner must produce a byte-identical envelope to sign_event_ed25519"
986        );
987        assert_eq!(signer.kid(), kid);
988        assert_eq!(signer.verifying_key(), anchor(31));
989
990        let mut keys = HashMap::new();
991        keys.insert(kid.to_string(), signer.verifying_key());
992        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
993        let verified =
994            verify_signed_event_envelope(&via_seam, &keys, &hmac_keys).expect("verify ok");
995        assert_eq!(verified.id, event.id);
996    }
997
998    #[test]
999    fn revocation_list_revokes_kid_and_fails_closed_on_tamper() {
1000        // S35 (ADR-0031) acceptance: a signed revocation envelope verifies under
1001        // the org-root key and yields the revoked set; a revoked kid is rejected
1002        // with signer_kid_revoked DESPITE a valid signature; an unrevoked kid
1003        // still verifies; a tampered revocation envelope fails closed.
1004        use crate::crypto::{provider, TrustAnchorPublicKey};
1005        use crate::types::{
1006            RevocationListV1, RevokedSigner, SignedTrustKeysetEnvelope, TrustKeysetSignature,
1007            TRUST_REVOCATION_V1_PAYLOAD_TYPE,
1008        };
1009        use crate::{
1010            verify_and_parse_revocation_envelope, verify_signed_event_envelope_with_revocations,
1011        };
1012        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1013        use base64::Engine as _;
1014        use std::collections::HashSet;
1015        use std::fmt::Write as _;
1016        use std::time::SystemTime;
1017
1018        // Org-root key that signs the revocation envelope.
1019        let org_seed = seed_bytes(42);
1020        let mut org_keys = HashMap::new();
1021        org_keys.insert(
1022            "org-root-2026".to_string(),
1023            TrustAnchorPublicKey::from_bytes_unchecked(
1024                provider().public_key_from_seed(&org_seed).unwrap(),
1025            ),
1026        );
1027
1028        // Build + org-root-sign a revocation list naming "kid-bad".
1029        let list = RevocationListV1 {
1030            schema_version: "1.0.0".into(),
1031            revocation_epoch: 1,
1032            revocations: vec![RevokedSigner {
1033                kid: "kid-bad".into(),
1034                revoked_at: "2026-06-23T00:00:00Z".into(),
1035                reason: "key_compromise".into(),
1036            }],
1037        };
1038        let payload_bytes = serde_json::to_vec(&list).unwrap();
1039        let sig = provider().sign_ed25519(&org_seed, &payload_bytes).unwrap();
1040        let mut digest_hex = String::from("sha256:");
1041        for b in provider().sha256(&payload_bytes) {
1042            write!(digest_hex, "{b:02x}").unwrap();
1043        }
1044        let make_env = |payload_b64: String, digest: String| SignedTrustKeysetEnvelope {
1045            schema_version: "1.0.0".into(),
1046            payload_type: TRUST_REVOCATION_V1_PAYLOAD_TYPE.into(),
1047            payload: payload_b64,
1048            signatures: vec![TrustKeysetSignature {
1049                signer_kid: "org-root-2026".into(),
1050                algorithm: "ed25519".into(),
1051                signature: URL_SAFE_NO_PAD.encode(sig),
1052                not_before: None,
1053                not_after: None,
1054            }],
1055            payload_digest: digest,
1056            produced_at: "2026-06-23T00:00:00Z".into(),
1057            replaces_envelope_digest: None,
1058            required_signer_count: None,
1059        };
1060        let envelope = make_env(URL_SAFE_NO_PAD.encode(&payload_bytes), digest_hex.clone());
1061
1062        // (3) The revocation envelope verifies under the org-root key.
1063        let revoked: HashSet<String> =
1064            verify_and_parse_revocation_envelope(&envelope, &org_keys, SystemTime::now())
1065                .expect("revocation envelope must verify under org-root");
1066        assert!(revoked.contains("kid-bad"));
1067
1068        // An event signed by the revoked kid, with a VALID signature.
1069        let event = sample_event();
1070        let bad_env = sign_event_ed25519(&event, "kid-bad", &seed_bytes(7)).unwrap();
1071        let mut event_keys = HashMap::new();
1072        event_keys.insert("kid-bad".to_string(), anchor(7));
1073        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
1074
1075        // (2) With no revocations the (valid) event verifies.
1076        let empty: HashSet<String> = HashSet::new();
1077        verify_signed_event_envelope_with_revocations(&bad_env, &event_keys, &hmac_keys, &empty)
1078            .expect("unrevoked kid with a valid signature must verify");
1079
1080        // (1) With the revoked set it is rejected as signer_kid_revoked — despite
1081        // the signature being valid.
1082        let err = verify_signed_event_envelope_with_revocations(
1083            &bad_env,
1084            &event_keys,
1085            &hmac_keys,
1086            &revoked,
1087        )
1088        .expect_err("a revoked kid must be rejected even with a valid signature");
1089        assert!(
1090            format!("{err}").contains("signer_kid_revoked"),
1091            "expected signer_kid_revoked, got: {err}"
1092        );
1093
1094        // (3, fail-closed) A tampered revocation envelope (payload swapped after
1095        // signing, so the digest/signature no longer match) yields no revocations.
1096        let tampered = make_env(
1097            URL_SAFE_NO_PAD
1098                .encode(br#"{"schemaVersion":"1.0.0","revocationEpoch":2,"revocations":[]}"#),
1099            digest_hex,
1100        );
1101        let err = verify_and_parse_revocation_envelope(&tampered, &org_keys, SystemTime::now())
1102            .expect_err("a tampered revocation envelope must fail closed");
1103        assert!(
1104            format!("{err}").contains("digest mismatch") || format!("{err}").contains("verify"),
1105            "expected fail-closed digest/verify error, got: {err}"
1106        );
1107    }
1108
1109    #[test]
1110    fn hmac_sha256_round_trip_verifies() {
1111        let key = b"super-secret-shared-symmetric-key";
1112        let event = sample_event();
1113        let envelope = sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
1114        assert_eq!(envelope.algorithm, "hmac-sha256");
1115
1116        let verifying_keys: HashMap<String, _> = HashMap::new();
1117        let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
1118        hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
1119        let verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
1120            .expect("verify ok");
1121        assert_eq!(verified.id, event.id);
1122    }
1123
1124    #[test]
1125    fn hmac_sha256_tampered_event_fails_verify() {
1126        let key = b"super-secret-shared-symmetric-key";
1127        let event = sample_event();
1128        let mut envelope =
1129            sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
1130        envelope.event.ty = "dev.cellos.events.cell.lifecycle.v1.destroyed".into();
1131
1132        let verifying_keys: HashMap<String, _> = HashMap::new();
1133        let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
1134        hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
1135        let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
1136            .expect_err("tampered event must fail");
1137        assert!(format!("{err}").contains("hmac-sha256 verify failed"));
1138    }
1139
1140    #[test]
1141    fn unknown_algorithm_rejected() {
1142        let seed = seed_bytes(31);
1143        let event = sample_event();
1144        let mut envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
1145        envelope.algorithm = "rsa-pss-sha512".into();
1146
1147        let verifying_keys: HashMap<String, _> = HashMap::new();
1148        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
1149        let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
1150            .expect_err("unknown algorithm must be rejected");
1151        assert!(format!("{err}").contains("unknown algorithm"));
1152    }
1153
1154    #[test]
1155    fn canonical_payload_is_deterministic() {
1156        let event = sample_event();
1157        let a = canonical_event_signing_payload(&event).expect("a");
1158        let b = canonical_event_signing_payload(&event).expect("b");
1159        assert_eq!(a, b, "canonical signing payload must be byte-identical");
1160    }
1161}