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, ed25519_dalek::VerifyingKey>` 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 ed25519_dalek::{Signature, SigningKey, VerifyingKey};
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48use sha2::{Digest, Sha256};
49
50use crate::error::CellosError;
51use crate::types::CloudEventV1;
52
53/// Parse the verifying-keys JSON document into a `kid → VerifyingKey` map.
54///
55/// The expected shape is a top-level JSON object (`{ "<kid>": "<base64url-pubkey>", ... }`).
56/// Each value MUST decode under base64url to exactly 32 bytes (Ed25519 raw
57/// public key length). Padding is tolerated to be friendly to publishers that
58/// emit padded base64url.
59///
60/// # Errors
61///
62/// Returns [`CellosError::InvalidSpec`] when:
63/// - the input is not valid JSON;
64/// - the top-level value is not a JSON object;
65/// - any value is not a string;
66/// - any value fails base64url decode;
67/// - any decoded value is not 32 bytes;
68/// - the JSON parser surfaces a duplicate kid (defense in depth — `serde_json`
69///   normally collapses duplicates).
70///
71/// An empty object is accepted (returns an empty map). The supervisor uses
72/// that as the "no operator keyring configured" path: envelope verification
73/// will then fail with `no signature verified` for any envelope whose signer
74/// kid is not in the empty keyring, which is the intended behaviour.
75pub fn parse_trust_verify_keys(raw: &str) -> Result<HashMap<String, VerifyingKey>, CellosError> {
76    let value: Value = serde_json::from_str(raw).map_err(|e| {
77        CellosError::InvalidSpec(format!("trust verify keys: JSON parse error: {e}"))
78    })?;
79
80    let object = value.as_object().ok_or_else(|| {
81        CellosError::InvalidSpec(
82            "trust verify keys: top-level value must be a JSON object mapping kid -> base64url-pubkey".into(),
83        )
84    })?;
85
86    // Defence in depth against parser-side duplicate-kid collapse: the JSON
87    // text is re-scanned to count each kid. `serde_json` collapses duplicate
88    // keys silently in `to_value`, so we walk the raw text via a streaming
89    // pass below before deferring to the parsed object for value extraction.
90    detect_duplicate_keys(raw)?;
91
92    let mut keys: HashMap<String, VerifyingKey> = HashMap::with_capacity(object.len());
93    for (kid, value) in object {
94        let pubkey_b64 = value.as_str().ok_or_else(|| {
95            CellosError::InvalidSpec(format!(
96                "trust verify keys: value for kid {kid:?} must be a base64url string, got {value}"
97            ))
98        })?;
99
100        // Tolerate padded or unpadded base64url.
101        let trimmed = pubkey_b64.trim_end_matches('=');
102        let bytes = URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
103            CellosError::InvalidSpec(format!(
104                "trust verify keys: kid {kid:?} value is not valid base64url: {e}"
105            ))
106        })?;
107
108        let array: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
109            CellosError::InvalidSpec(format!(
110                "trust verify keys: kid {kid:?} decoded to {} bytes, expected 32",
111                bytes.len()
112            ))
113        })?;
114
115        let verifying_key = VerifyingKey::from_bytes(&array).map_err(|e| {
116            CellosError::InvalidSpec(format!(
117                "trust verify keys: kid {kid:?} is not a valid Ed25519 verifying key: {e}"
118            ))
119        })?;
120
121        keys.insert(kid.clone(), verifying_key);
122    }
123
124    Ok(keys)
125}
126
127/// Read [`parse_trust_verify_keys`]'s input from a path and decode it.
128///
129/// On Unix this opens with `O_NOFOLLOW` (matching `CELLOS_POLICY_PACK_PATH` /
130/// `CELLOS_AUTHORITY_KEYS_PATH` policy in `composition.rs` — SEC-15b) so the
131/// final path component cannot be a symlink redirected at an
132/// attacker-controlled file. On non-Unix [`std::fs::read_to_string`] is used
133/// (Windows lacks an `O_NOFOLLOW` analogue in the std API).
134///
135/// # Errors
136///
137/// Returns [`CellosError::InvalidSpec`] when the file cannot be opened, read,
138/// or decoded as UTF-8, plus every error class from [`parse_trust_verify_keys`].
139pub fn load_trust_verify_keys_file(
140    path: &Path,
141) -> Result<HashMap<String, VerifyingKey>, CellosError> {
142    #[cfg(unix)]
143    let raw = {
144        use std::io::Read;
145        use std::os::unix::fs::OpenOptionsExt;
146        let mut opts = std::fs::OpenOptions::new();
147        opts.read(true);
148        // O_NOFOLLOW value is platform-specific. cellos-core deliberately
149        // avoids a `libc` dependency, so we hard-code the kernel ABI values
150        // for the runtime targets we care about. Adding a new Unix variant
151        // here is a one-line change, not a libc-crate refactor.
152        //   - Linux: octal 0o400000 == 0x20000  (asm-generic/fcntl.h)
153        //   - macOS / *BSD:           == 0x100  (sys/fcntl.h)
154        // Using the wrong constant silently maps to a different flag (on
155        // Linux 0x100 is `O_NOCTTY`, which would *not* refuse a symlink),
156        // so this MUST stay accurate per platform.
157        #[cfg(target_os = "linux")]
158        const O_NOFOLLOW: i32 = 0x20000;
159        #[cfg(any(
160            target_os = "macos",
161            target_os = "ios",
162            target_os = "freebsd",
163            target_os = "netbsd",
164            target_os = "openbsd",
165            target_os = "dragonfly",
166        ))]
167        const O_NOFOLLOW: i32 = 0x100;
168        // Build break here on a new Unix is intentional: pick the right
169        // constant from the platform's <fcntl.h> rather than guessing.
170        #[cfg(not(any(
171            target_os = "linux",
172            target_os = "macos",
173            target_os = "ios",
174            target_os = "freebsd",
175            target_os = "netbsd",
176            target_os = "openbsd",
177            target_os = "dragonfly",
178        )))]
179        compile_error!(
180            "cellos-core::trust_keys: O_NOFOLLOW value not yet defined for this Unix target — \
181             add the platform-specific value (see <fcntl.h>) before building."
182        );
183        opts.custom_flags(O_NOFOLLOW);
184        let mut file = opts.open(path).map_err(|e| {
185            CellosError::InvalidSpec(format!(
186                "trust verify keys: cannot open {}: {e}",
187                path.display()
188            ))
189        })?;
190        let mut buf = String::new();
191        file.read_to_string(&mut buf).map_err(|e| {
192            CellosError::InvalidSpec(format!(
193                "trust verify keys: cannot read {}: {e}",
194                path.display()
195            ))
196        })?;
197        buf
198    };
199    #[cfg(not(unix))]
200    let raw = std::fs::read_to_string(path).map_err(|e| {
201        CellosError::InvalidSpec(format!(
202            "trust verify keys: cannot read {}: {e}",
203            path.display()
204        ))
205    })?;
206
207    parse_trust_verify_keys(&raw)
208}
209
210/// Single-pass duplicate-key detector for the top-level JSON object.
211///
212/// `serde_json`'s `Value` collapses duplicate object keys with last-write-wins
213/// semantics. For a verifying-keys file that's a silent footgun: an attacker
214/// who can inject a second copy of an existing kid with a different pubkey
215/// would silently substitute the verifier's key. This walker scans the raw
216/// JSON text for top-level object string keys and rejects the file if any
217/// kid appears twice.
218///
219/// The walker is deliberately simple — it tracks string state and a single
220/// nesting depth so it only counts keys at the outermost object — and does
221/// not attempt to fully reparse JSON. It is robust against escaped quotes,
222/// nested objects, arrays, and whitespace; if the structure is malformed in
223/// a way the walker can't reason about, it falls through and lets
224/// `serde_json::from_str` (called by the caller) surface the parse error.
225fn detect_duplicate_keys(raw: &str) -> Result<(), CellosError> {
226    use std::collections::HashSet;
227
228    let bytes = raw.as_bytes();
229    let mut seen: HashSet<String> = HashSet::new();
230    let mut idx = 0;
231    let mut depth: i32 = 0;
232    let mut in_string = false;
233    let mut after_colon_in_outer = false;
234    let mut current_key: Option<String> = None;
235    let mut escape = false;
236    let mut started = false;
237
238    while idx < bytes.len() {
239        let b = bytes[idx];
240        if in_string {
241            if escape {
242                escape = false;
243                if let Some(k) = current_key.as_mut() {
244                    k.push(b as char);
245                }
246                idx += 1;
247                continue;
248            }
249            match b {
250                b'\\' => {
251                    escape = true;
252                    if let Some(k) = current_key.as_mut() {
253                        k.push(b as char);
254                    }
255                }
256                b'"' => {
257                    in_string = false;
258                    if depth == 1 && !after_colon_in_outer {
259                        if let Some(key) = current_key.take() {
260                            if !seen.insert(key.clone()) {
261                                return Err(CellosError::InvalidSpec(format!(
262                                    "trust verify keys: duplicate kid {key:?} in keys file"
263                                )));
264                            }
265                        }
266                    } else {
267                        // string was a value, not a key — discard.
268                        let _ = current_key.take();
269                    }
270                }
271                _ => {
272                    if let Some(k) = current_key.as_mut() {
273                        k.push(b as char);
274                    }
275                }
276            }
277            idx += 1;
278            continue;
279        }
280
281        match b {
282            b'"' => {
283                in_string = true;
284                // Only collect strings that could be top-level keys: depth==1
285                // AND we are NOT after a colon (i.e. we expect a key here).
286                if depth == 1 && !after_colon_in_outer {
287                    current_key = Some(String::new());
288                } else {
289                    current_key = Some(String::new()); // placeholder so the
290                                                       // closing quote branch
291                                                       // discards uniformly.
292                }
293            }
294            b'{' => {
295                depth += 1;
296                started = true;
297            }
298            b'}' => {
299                depth -= 1;
300                after_colon_in_outer = false;
301                if depth == 0 {
302                    return Ok(());
303                }
304            }
305            b'[' => {
306                depth += 1;
307            }
308            b']' => {
309                depth -= 1;
310            }
311            b':' => {
312                if depth == 1 {
313                    after_colon_in_outer = true;
314                }
315            }
316            b',' => {
317                if depth == 1 {
318                    after_colon_in_outer = false;
319                }
320            }
321            _ => {}
322        }
323        idx += 1;
324    }
325
326    // Reached end of input without closing the outermost object: let
327    // serde_json surface the structural error. Treat as no-duplicate-detected
328    // here (the parse will fail later regardless).
329    let _ = started;
330    Ok(())
331}
332
333// ── I5: Per-event signing (HMAC-SHA256 / Ed25519) ───────────────────────────
334//
335// Extends the SEC-25 envelope-verification model down to individual
336// CloudEvents so JetStream consumers / projectors can independently verify
337// authorship of a single event without re-walking the keyset envelope.
338//
339// Doctrine: D1 — this is an OPT-IN signing path. Producers that don't sign
340// emit raw `CloudEventV1` envelopes exactly as before; consumers that don't
341// verify see no change.
342//
343// Algorithms:
344//   - "ed25519": producer signs the canonical-JSON serialization with an
345//     Ed25519 signing key; consumer verifies with the matching public key.
346//   - "hmac-sha256": shared symmetric key; FIPS 198 HMAC over the canonical
347//     JSON serialization. Implemented inline over `sha2::Sha256` so we
348//     don't pull a new crate dependency.
349//
350// `notBefore` / `notAfter` mirror the trust-keyset envelope schema and are
351// advisory — this primitive does not enforce them.
352
353/// Per-event signed envelope wrapping a single CloudEvent.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase")]
356pub struct SignedEventEnvelopeV1 {
357    pub event: CloudEventV1,
358    pub signer_kid: String,
359    pub algorithm: String,
360    pub signature: String,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub not_before: Option<String>,
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub not_after: Option<String>,
365}
366
367/// Canonical JSON form of a CloudEvent for signing/verification.
368pub fn canonical_event_signing_payload(event: &CloudEventV1) -> Result<Vec<u8>, CellosError> {
369    serde_json::to_vec(event).map_err(|e| {
370        CellosError::InvalidSpec(format!("canonical_event_signing_payload: serialize: {e}"))
371    })
372}
373
374/// Sign a CloudEvent with an Ed25519 signing key.
375pub fn sign_event_ed25519(
376    event: &CloudEventV1,
377    signer_kid: &str,
378    signing_key: &SigningKey,
379) -> Result<SignedEventEnvelopeV1, CellosError> {
380    use ed25519_dalek::Signer;
381    let payload = canonical_event_signing_payload(event)?;
382    let signature = signing_key.sign(&payload);
383    Ok(SignedEventEnvelopeV1 {
384        event: event.clone(),
385        signer_kid: signer_kid.to_string(),
386        algorithm: "ed25519".to_string(),
387        signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
388        not_before: None,
389        not_after: None,
390    })
391}
392
393/// Sign a CloudEvent with HMAC-SHA256 (FIPS 198).
394pub fn sign_event_hmac_sha256(
395    event: &CloudEventV1,
396    signer_kid: &str,
397    key_bytes: &[u8],
398) -> Result<SignedEventEnvelopeV1, CellosError> {
399    let payload = canonical_event_signing_payload(event)?;
400    let mac = hmac_sha256(key_bytes, &payload);
401    Ok(SignedEventEnvelopeV1 {
402        event: event.clone(),
403        signer_kid: signer_kid.to_string(),
404        algorithm: "hmac-sha256".to_string(),
405        signature: URL_SAFE_NO_PAD.encode(mac),
406        not_before: None,
407        not_after: None,
408    })
409}
410
411/// Verify a [`SignedEventEnvelopeV1`] against a verifier-side keyring.
412pub fn verify_signed_event_envelope<'a>(
413    envelope: &'a SignedEventEnvelopeV1,
414    verifying_keys: &HashMap<String, VerifyingKey>,
415    hmac_keys: &HashMap<String, Vec<u8>>,
416) -> Result<&'a CloudEventV1, CellosError> {
417    let payload = canonical_event_signing_payload(&envelope.event)?;
418    let sig_b64 = envelope.signature.trim_end_matches('=');
419    let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|e| {
420        CellosError::InvalidSpec(format!(
421            "signed event envelope: signature is not valid base64url: {e}"
422        ))
423    })?;
424
425    match envelope.algorithm.as_str() {
426        "ed25519" => {
427            let verifying_key = verifying_keys.get(&envelope.signer_kid).ok_or_else(|| {
428                CellosError::InvalidSpec(format!(
429                    "signed event envelope: unknown ed25519 signer kid {:?}",
430                    envelope.signer_kid
431                ))
432            })?;
433            let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
434                CellosError::InvalidSpec(format!(
435                    "signed event envelope: ed25519 signature must be 64 bytes, got {}",
436                    sig_bytes.len()
437                ))
438            })?;
439            let signature = Signature::from_bytes(&sig_array);
440            verifying_key
441                .verify_strict(&payload, &signature)
442                .map_err(|e| {
443                    CellosError::InvalidSpec(format!(
444                        "signed event envelope: ed25519 verify failed: {e}"
445                    ))
446                })?;
447            Ok(&envelope.event)
448        }
449        "hmac-sha256" => {
450            let key = hmac_keys.get(&envelope.signer_kid).ok_or_else(|| {
451                CellosError::InvalidSpec(format!(
452                    "signed event envelope: unknown hmac-sha256 signer kid {:?}",
453                    envelope.signer_kid
454                ))
455            })?;
456            if sig_bytes.len() != 32 {
457                return Err(CellosError::InvalidSpec(format!(
458                    "signed event envelope: hmac-sha256 mac must be 32 bytes, got {}",
459                    sig_bytes.len()
460                )));
461            }
462            let expected = hmac_sha256(key, &payload);
463            if !constant_time_eq(&expected, &sig_bytes) {
464                return Err(CellosError::InvalidSpec(
465                    "signed event envelope: hmac-sha256 verify failed".into(),
466                ));
467            }
468            Ok(&envelope.event)
469        }
470        other => Err(CellosError::InvalidSpec(format!(
471            "signed event envelope: unknown algorithm {other:?} (expected ed25519 or hmac-sha256)"
472        ))),
473    }
474}
475
476/// HMAC-SHA256 (RFC 2104 / FIPS 198). Returns the 32-byte MAC.
477fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
478    const BLOCK: usize = 64;
479    let mut block_key = [0u8; BLOCK];
480    if key.len() > BLOCK {
481        let mut hasher = Sha256::new();
482        hasher.update(key);
483        let digest = hasher.finalize();
484        block_key[..32].copy_from_slice(&digest);
485    } else {
486        block_key[..key.len()].copy_from_slice(key);
487    }
488
489    let mut ipad = [0u8; BLOCK];
490    let mut opad = [0u8; BLOCK];
491    for i in 0..BLOCK {
492        ipad[i] = block_key[i] ^ 0x36;
493        opad[i] = block_key[i] ^ 0x5c;
494    }
495
496    let mut inner = Sha256::new();
497    inner.update(ipad);
498    inner.update(message);
499    let inner_digest = inner.finalize();
500
501    let mut outer = Sha256::new();
502    outer.update(opad);
503    outer.update(inner_digest);
504    let mac = outer.finalize();
505
506    let mut out = [0u8; 32];
507    out.copy_from_slice(&mac);
508    out
509}
510
511/// Constant-time equality over byte slices of equal length.
512fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
513    if a.len() != b.len() {
514        return false;
515    }
516    let mut diff: u8 = 0;
517    for (x, y) in a.iter().zip(b.iter()) {
518        diff |= x ^ y;
519    }
520    diff == 0
521}
522
523#[cfg(test)]
524mod tests {
525    use super::{load_trust_verify_keys_file, parse_trust_verify_keys};
526    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
527    use base64::Engine as _;
528    use ed25519_dalek::SigningKey;
529    use std::io::Write;
530
531    fn signing_key(seed: u8) -> SigningKey {
532        SigningKey::from_bytes(&[seed; 32])
533    }
534
535    fn pubkey_b64(seed: u8) -> String {
536        let signer = signing_key(seed);
537        URL_SAFE_NO_PAD.encode(signer.verifying_key().to_bytes())
538    }
539
540    #[test]
541    fn parses_well_formed_two_key_map() {
542        let raw = format!(
543            r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q3": "{}" }}"#,
544            pubkey_b64(7),
545            pubkey_b64(11)
546        );
547        let keys = parse_trust_verify_keys(&raw).expect("well-formed map must parse");
548        assert_eq!(keys.len(), 2);
549        assert!(keys.contains_key("ops-envelope-2026-q2"));
550        assert!(keys.contains_key("ops-envelope-2026-q3"));
551        assert_eq!(
552            keys["ops-envelope-2026-q2"],
553            signing_key(7).verifying_key(),
554            "kid q2 must round-trip to its source verifying key"
555        );
556    }
557
558    #[test]
559    fn rejects_duplicate_kid() {
560        let raw = format!(
561            r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q2": "{}" }}"#,
562            pubkey_b64(7),
563            pubkey_b64(11)
564        );
565        let err = parse_trust_verify_keys(&raw).expect_err("duplicate kid must be rejected");
566        let msg = format!("{err}");
567        assert!(
568            msg.contains("duplicate kid"),
569            "expected duplicate-kid error, got: {msg}"
570        );
571    }
572
573    #[test]
574    fn rejects_malformed_base64() {
575        let raw = r#"{ "ops-bad": "@@@not-base64@@@" }"#;
576        let err = parse_trust_verify_keys(raw).expect_err("malformed base64 must be rejected");
577        let msg = format!("{err}");
578        assert!(
579            msg.contains("not valid base64url"),
580            "expected base64-decode error, got: {msg}"
581        );
582    }
583
584    #[test]
585    fn rejects_wrong_length_pubkey() {
586        // 16 bytes of zeros, base64url-encoded — too short for an Ed25519 pubkey.
587        let too_short = URL_SAFE_NO_PAD.encode([0u8; 16]);
588        let raw = format!(r#"{{ "ops-short": "{too_short}" }}"#);
589        let err = parse_trust_verify_keys(&raw).expect_err("16-byte pubkey must be rejected");
590        let msg = format!("{err}");
591        assert!(
592            msg.contains("expected 32"),
593            "expected 32-byte length error, got: {msg}"
594        );
595    }
596
597    #[test]
598    fn empty_object_is_accepted() {
599        let raw = "{}";
600        let keys = parse_trust_verify_keys(raw).expect("empty object is the no-keys case");
601        assert!(keys.is_empty());
602    }
603
604    #[test]
605    fn missing_file_errors() {
606        let path = std::path::Path::new("/nonexistent/path/that/should/not/exist.json");
607        let err =
608            load_trust_verify_keys_file(path).expect_err("missing file must surface an error");
609        let msg = format!("{err}");
610        assert!(
611            msg.contains("cannot") && msg.contains("nonexistent"),
612            "expected file-open error, got: {msg}"
613        );
614    }
615
616    #[test]
617    fn rejects_non_utf8_input() {
618        let dir = tempfile::tempdir().expect("tmpdir");
619        let path = dir.path().join("trust-keys-non-utf8.json");
620        let mut f = std::fs::File::create(&path).expect("create");
621        // Bytes that are NOT valid UTF-8.
622        f.write_all(&[0xFF, 0xFE, 0xFD, 0xFC]).expect("write");
623        drop(f);
624        let err = load_trust_verify_keys_file(&path).expect_err("non-utf8 must error");
625        let msg = format!("{err}");
626        // On Unix this surfaces from `read_to_string`'s utf8 check.
627        assert!(
628            msg.contains("cannot read") || msg.contains("utf-8") || msg.contains("UTF-8"),
629            "expected non-utf8 read error, got: {msg}"
630        );
631    }
632
633    #[test]
634    fn rejects_top_level_non_object() {
635        let raw = r#"["not", "an", "object"]"#;
636        let err = parse_trust_verify_keys(raw).expect_err("top-level non-object must be rejected");
637        let msg = format!("{err}");
638        assert!(
639            msg.contains("must be a JSON object"),
640            "expected top-level-object error, got: {msg}"
641        );
642    }
643
644    #[test]
645    fn loads_valid_file_via_load_helper() {
646        // Round-trip the file path: write a well-formed two-key map and load
647        // it back via the on-disk helper, exercising the O_NOFOLLOW path on
648        // Unix.
649        let dir = tempfile::tempdir().expect("tmpdir");
650        let path = dir.path().join("trust-keys.json");
651        let raw = format!(
652            r#"{{ "kid-active-7": "{}", "kid-active-11": "{}" }}"#,
653            pubkey_b64(7),
654            pubkey_b64(11)
655        );
656        std::fs::write(&path, raw).expect("write keys");
657        let keys = load_trust_verify_keys_file(&path).expect("load via helper");
658        assert_eq!(keys.len(), 2);
659        assert_eq!(keys["kid-active-7"], signing_key(7).verifying_key());
660    }
661
662    /// Symlink rejection — proves O_NOFOLLOW is the right kernel flag on this
663    /// platform. Without this test we silently shipped `O_NOCTTY` on Linux
664    /// (0x100 is O_NOCTTY there; O_NOFOLLOW is 0x20000) and the loader would
665    /// accept attacker-swappable symlinks. Pin the property so a future rename
666    /// or constant edit can't regress without a failing test.
667    #[cfg(unix)]
668    #[test]
669    fn load_helper_rejects_symlink_at_final_component() {
670        let dir = tempfile::tempdir().expect("tmpdir");
671        let real_path = dir.path().join("trust-keys-real.json");
672        let symlink_path = dir.path().join("trust-keys-symlink.json");
673        let raw = format!(r#"{{ "kid-only-1": "{}" }}"#, pubkey_b64(7));
674        std::fs::write(&real_path, raw).expect("write real keys file");
675        std::os::unix::fs::symlink(&real_path, &symlink_path).expect("create symlink");
676
677        // Sanity: reading the real file works.
678        load_trust_verify_keys_file(&real_path).expect("real path loads");
679
680        // The symlink at the final component MUST be rejected by O_NOFOLLOW.
681        let err = load_trust_verify_keys_file(&symlink_path)
682            .expect_err("symlink at final component must be rejected");
683        let msg = format!("{err}");
684        assert!(
685            msg.contains("cannot open"),
686            "expected open-side rejection, got: {msg}"
687        );
688    }
689
690    // ── I5: per-event signing primitives ───────────────────────────────────
691
692    use super::{
693        canonical_event_signing_payload, sign_event_ed25519, sign_event_hmac_sha256,
694        verify_signed_event_envelope,
695    };
696    use crate::types::CloudEventV1;
697    use std::collections::HashMap;
698
699    fn sample_event() -> CloudEventV1 {
700        CloudEventV1 {
701            specversion: "1.0".into(),
702            id: "ev-001".into(),
703            source: "/cellos-supervisor".into(),
704            ty: "dev.cellos.events.cell.lifecycle.v1.started".into(),
705            datacontenttype: Some("application/json".into()),
706            data: Some(serde_json::json!({"cellId": "test-cell-1"})),
707            time: Some("2026-05-06T12:00:00Z".into()),
708            traceparent: None,
709        }
710    }
711
712    #[test]
713    fn ed25519_round_trip_verifies() {
714        let signer = signing_key(31);
715        let event = sample_event();
716        let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
717
718        assert_eq!(envelope.algorithm, "ed25519");
719        assert_eq!(envelope.signer_kid, "ops-event-2026-q2");
720
721        let mut keys = HashMap::new();
722        keys.insert("ops-event-2026-q2".to_string(), signer.verifying_key());
723        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
724        let verified =
725            verify_signed_event_envelope(&envelope, &keys, &hmac_keys).expect("verify ok");
726        assert_eq!(verified.id, event.id);
727    }
728
729    #[test]
730    fn ed25519_tampered_event_fails_verify() {
731        let signer = signing_key(31);
732        let event = sample_event();
733        let mut envelope =
734            sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
735        envelope.event.id = "ev-tampered".into();
736
737        let mut keys = HashMap::new();
738        keys.insert("ops-event-2026-q2".to_string(), signer.verifying_key());
739        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
740        let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
741            .expect_err("tampered event must fail verify");
742        assert!(format!("{err}").contains("ed25519 verify failed"));
743    }
744
745    #[test]
746    fn ed25519_unknown_kid_fails_verify() {
747        let signer = signing_key(31);
748        let event = sample_event();
749        let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
750        let keys: HashMap<String, _> = HashMap::new();
751        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
752        let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
753            .expect_err("unknown kid must fail");
754        assert!(format!("{err}").contains("unknown ed25519 signer kid"));
755    }
756
757    #[test]
758    fn hmac_sha256_round_trip_verifies() {
759        let key = b"super-secret-shared-symmetric-key";
760        let event = sample_event();
761        let envelope = sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
762        assert_eq!(envelope.algorithm, "hmac-sha256");
763
764        let verifying_keys: HashMap<String, _> = HashMap::new();
765        let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
766        hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
767        let verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
768            .expect("verify ok");
769        assert_eq!(verified.id, event.id);
770    }
771
772    #[test]
773    fn hmac_sha256_tampered_event_fails_verify() {
774        let key = b"super-secret-shared-symmetric-key";
775        let event = sample_event();
776        let mut envelope =
777            sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
778        envelope.event.ty = "dev.cellos.events.cell.lifecycle.v1.destroyed".into();
779
780        let verifying_keys: HashMap<String, _> = HashMap::new();
781        let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
782        hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
783        let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
784            .expect_err("tampered event must fail");
785        assert!(format!("{err}").contains("hmac-sha256 verify failed"));
786    }
787
788    #[test]
789    fn unknown_algorithm_rejected() {
790        let signer = signing_key(31);
791        let event = sample_event();
792        let mut envelope =
793            sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
794        envelope.algorithm = "rsa-pss-sha512".into();
795
796        let verifying_keys: HashMap<String, _> = HashMap::new();
797        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
798        let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
799            .expect_err("unknown algorithm must be rejected");
800        assert!(format!("{err}").contains("unknown algorithm"));
801    }
802
803    #[test]
804    fn canonical_payload_is_deterministic() {
805        let event = sample_event();
806        let a = canonical_event_signing_payload(&event).expect("a");
807        let b = canonical_event_signing_payload(&event).expect("b");
808        assert_eq!(a, b, "canonical signing payload must be byte-identical");
809    }
810}