Skip to main content

cellos_supervisor/
trust_keyset_load.rs

1//! SEC-25 Phase 2 — supervisor-side wiring of `CELLOS_TRUST_VERIFY_KEYS_PATH`
2//! and `CELLOS_TRUST_KEYSET_PATH`.
3//!
4//! W2 SEC-25 Phase 1 shipped the dataplane verifier
5//! `cellos_core::verify_signed_trust_keyset_envelope`. This module turns the
6//! three operator-facing env vars into a startup-time trust-keyset
7//! establishment step:
8//!
9//! | Env var                                | Required | Effect                                                                 |
10//! |----------------------------------------|----------|------------------------------------------------------------------------|
11//! | `CELLOS_TRUST_VERIFY_KEYS_PATH`        | optional | Operator's `kid → base64url-pubkey` JSON file (see `cellos_core::trust_keys`). |
12//! | `CELLOS_TRUST_KEYSET_PATH`             | optional | Path to a `signed-trust-keyset-envelope-v1` document to verify.        |
13//! | `CELLOS_REQUIRE_TRUST_VERIFY_KEYS=1`   | optional | Fail-closed: build_supervisor errors if the verifying-keys file is missing OR if the envelope does not verify. |
14//!
15//! When the verifying-keys path is unset and `REQUIRE` is unset, the
16//! supervisor runs with an empty trust keyring (the legacy / zero-config
17//! posture); any envelope reference will surface a `keyset_verification_failed`
18//! event instead of failing startup.
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use cellos_core::{
25    cloud_event_v1_keyset_verification_failed, cloud_event_v1_keyset_verified,
26    load_trust_verify_keys_file, ports::EventSink, verify_signed_trust_keyset_chain,
27    verify_signed_trust_keyset_envelope, SignedTrustKeysetEnvelope,
28};
29use ed25519_dalek::VerifyingKey;
30use serde_json::Value;
31
32/// Outcome of the optional `CELLOS_TRUST_KEYSET_PATH` envelope verification.
33///
34/// Returned by [`load_and_verify_trust_keyset_from_env`]. The caller (typically
35/// `build_supervisor`) is responsible for emitting the resulting CloudEvent
36/// through whichever sink is wired up.
37#[derive(Debug, Clone)]
38pub enum KeysetLoadOutcome {
39    /// `CELLOS_TRUST_KEYSET_PATH` was unset — no verification attempted.
40    NotConfigured,
41    /// Envelope verified successfully against the supplied keyring.
42    Verified(KeysetVerifiedDetails),
43    /// Envelope load or verification failed in fail-open mode (the supervisor
44    /// continues to run; an event surfaces the degraded posture). Fail-closed
45    /// errors are returned from the call as `Err` and never surface here.
46    Failed(KeysetVerificationFailedDetails),
47}
48
49#[derive(Debug, Clone)]
50pub struct KeysetVerifiedDetails {
51    pub keyset_id: String,
52    pub payload_digest: String,
53    pub verified_signer_kid: String,
54}
55
56#[derive(Debug, Clone)]
57pub struct KeysetVerificationFailedDetails {
58    pub attempted_keyset_basename: String,
59    pub reason: String,
60}
61
62/// Read `CELLOS_TRUST_VERIFY_KEYS_PATH` and parse the operator's keyring.
63///
64/// - Unset and `require_trust_verify_keys` is `false` → returns an empty map
65///   (legacy / zero-config posture). The supervisor still runs; envelope
66///   references surface `keyset_verification_failed` events.
67/// - Unset and `require_trust_verify_keys` is `true` → returns an error.
68/// - Set and load fails:
69///   - `require_trust_verify_keys: true` → returns the load error.
70///   - `require_trust_verify_keys: false` → returns the load error (fail-open
71///     does not apply to the keyring file itself — only to the envelope
72///     verification step). A misconfigured keys file is unambiguously an
73///     operator-side bug; silently empty-keyring-falling-through would mask
74///     it.
75pub fn load_trust_verify_keys_from_env(
76    require_trust_verify_keys: bool,
77) -> Result<Arc<HashMap<String, VerifyingKey>>, anyhow::Error> {
78    match std::env::var_os("CELLOS_TRUST_VERIFY_KEYS_PATH") {
79        None => {
80            if require_trust_verify_keys {
81                return Err(anyhow::anyhow!(
82                    "CELLOS_REQUIRE_TRUST_VERIFY_KEYS is set but CELLOS_TRUST_VERIFY_KEYS_PATH is unset"
83                ));
84            }
85            tracing::debug!(
86                target: "cellos.supervisor.trust",
87                "CELLOS_TRUST_VERIFY_KEYS_PATH unset; supervisor runs with empty trust keyring"
88            );
89            Ok(Arc::new(HashMap::new()))
90        }
91        Some(path_os) => {
92            let path = PathBuf::from(&path_os);
93            let keys = load_trust_verify_keys_file(&path).map_err(|e| {
94                anyhow::anyhow!(
95                    "CELLOS_TRUST_VERIFY_KEYS_PATH: cannot load '{}': {e}",
96                    path.display()
97                )
98            })?;
99            tracing::info!(
100                target: "cellos.supervisor.trust",
101                path = %path.display(),
102                kid_count = keys.len(),
103                "trust verifying keys loaded"
104            );
105            Ok(Arc::new(keys))
106        }
107    }
108}
109
110/// Read `CELLOS_TRUST_KEYSET_PATH` and verify the envelope against `keys`.
111///
112/// Behavior matrix:
113///
114/// | Env var unset | Outcome                                |
115/// |---------------|----------------------------------------|
116/// | yes           | [`KeysetLoadOutcome::NotConfigured`]   |
117///
118/// | Env var set, fail-closed (`require_trust_verify_keys=true`) | Outcome on failure |
119/// |-------------------------------------------------------------|--------------------|
120/// | file open / read error                                      | `Err`              |
121/// | JSON parse error                                            | `Err`              |
122/// | `verify_signed_trust_keyset_envelope` error                 | `Err`              |
123///
124/// | Env var set, fail-open (`require_trust_verify_keys=false`) | Outcome on failure |
125/// |------------------------------------------------------------|--------------------|
126/// | file open / read error                                     | [`KeysetLoadOutcome::Failed`] |
127/// | JSON parse error                                           | [`KeysetLoadOutcome::Failed`] |
128/// | `verify_signed_trust_keyset_envelope` error                | [`KeysetLoadOutcome::Failed`] |
129///
130/// On success, the supervisor decodes the inner `keysetId` from the raw
131/// payload bytes (minimal `{"keysetId":"..."}` extraction — full keyset shape
132/// validation is not the supervisor's job; the cellos-trustd sibling repo
133/// owns that). If decoding the inner `keysetId` fails (the payload is not a
134/// JSON object or has no string-typed `keysetId`), the envelope is still
135/// considered verified at the signature level, but the event reports
136/// `keysetId = "(unknown)"`.
137pub fn load_and_verify_trust_keyset_from_env(
138    keys: &HashMap<String, VerifyingKey>,
139    require_trust_verify_keys: bool,
140    now: std::time::SystemTime,
141) -> Result<KeysetLoadOutcome, anyhow::Error> {
142    // scope: chain-mode wins when both env vars are set. The HEAD envelope
143    // of the chain becomes the "verified envelope" for event-emit purposes;
144    // the chain integrity check is logged additionally.
145    if let Some(chain_path_os) = std::env::var_os("CELLOS_TRUST_KEYSET_CHAIN_PATH") {
146        return load_and_verify_chain_from_env(chain_path_os, keys, require_trust_verify_keys, now);
147    }
148
149    let Some(path_os) = std::env::var_os("CELLOS_TRUST_KEYSET_PATH") else {
150        return Ok(KeysetLoadOutcome::NotConfigured);
151    };
152    let path = PathBuf::from(&path_os);
153    let basename = path
154        .file_name()
155        .map(|s| s.to_string_lossy().into_owned())
156        .unwrap_or_else(|| "(unknown)".to_string());
157
158    match attempt_load_and_verify(&path, keys, now) {
159        Ok(details) => {
160            tracing::info!(
161                target: "cellos.supervisor.trust",
162                keyset_id = %details.keyset_id,
163                payload_digest = %details.payload_digest,
164                verified_signer_kid = %details.verified_signer_kid,
165                "signed trust keyset envelope verified at supervisor startup"
166            );
167            Ok(KeysetLoadOutcome::Verified(details))
168        }
169        Err(reason_string) => {
170            if require_trust_verify_keys {
171                return Err(anyhow::anyhow!(
172                    "CELLOS_TRUST_KEYSET_PATH: verification failed under CELLOS_REQUIRE_TRUST_VERIFY_KEYS=1: {reason_string}"
173                ));
174            }
175            tracing::warn!(
176                target: "cellos.supervisor.trust",
177                attempted_keyset_basename = %basename,
178                reason = %reason_string,
179                "signed trust keyset envelope verification failed; continuing in degraded mode"
180            );
181            Ok(KeysetLoadOutcome::Failed(KeysetVerificationFailedDetails {
182                attempted_keyset_basename: basename,
183                reason: reason_string,
184            }))
185        }
186    }
187}
188
189/// scope: chain-mode loader — reads the comma-or-newline-separated list of
190/// envelope paths from `CELLOS_TRUST_KEYSET_CHAIN_PATH` (oldest-first) and
191/// verifies the chain via `verify_signed_trust_keyset_chain`.
192///
193/// On success, the HEAD envelope is what surfaces in the
194/// `keyset_verified` CloudEvent (its `keysetId`, `payloadDigest`, and
195/// verifying signer kid). The fact that an N-envelope chain was walked is
196/// logged at the `cellos.supervisor.trust` target for operator audit; it
197/// does not currently appear in the event payload.
198///
199/// Path-list parsing: splits on commas AND newlines; empty entries are
200/// skipped (so `path1,,path2` parses as `[path1, path2]`).
201fn load_and_verify_chain_from_env(
202    chain_path_os: std::ffi::OsString,
203    keys: &HashMap<String, VerifyingKey>,
204    require_trust_verify_keys: bool,
205    now: std::time::SystemTime,
206) -> Result<KeysetLoadOutcome, anyhow::Error> {
207    let raw_str = chain_path_os.to_string_lossy().into_owned();
208    let paths: Vec<PathBuf> = raw_str
209        .split([',', '\n'])
210        .map(str::trim)
211        .filter(|s| !s.is_empty())
212        .map(PathBuf::from)
213        .collect();
214
215    if paths.is_empty() {
216        let reason = "CELLOS_TRUST_KEYSET_CHAIN_PATH set but parsed no envelope paths".to_string();
217        if require_trust_verify_keys {
218            return Err(anyhow::anyhow!(
219                "CELLOS_TRUST_KEYSET_CHAIN_PATH: verification failed under CELLOS_REQUIRE_TRUST_VERIFY_KEYS=1: {reason}"
220            ));
221        }
222        tracing::warn!(
223            target: "cellos.supervisor.trust",
224            reason = %reason,
225            "signed trust keyset chain verification failed; continuing in degraded mode"
226        );
227        return Ok(KeysetLoadOutcome::Failed(KeysetVerificationFailedDetails {
228            attempted_keyset_basename: "(empty chain)".into(),
229            reason,
230        }));
231    }
232
233    // Use the HEAD envelope's basename for event emission — that's the one
234    // an operator will compare against their rotation publish step.
235    let head_basename = paths
236        .last()
237        .and_then(|p| p.file_name())
238        .map(|s| s.to_string_lossy().into_owned())
239        .unwrap_or_else(|| "(unknown)".to_string());
240
241    match attempt_load_and_verify_chain(&paths, keys, now) {
242        Ok(details) => {
243            tracing::info!(
244                target: "cellos.supervisor.trust",
245                envelope_count = paths.len(),
246                keyset_id = %details.keyset_id,
247                payload_digest = %details.payload_digest,
248                verified_signer_kid = %details.verified_signer_kid,
249                "verified {}-envelope chain (head digest: {})",
250                paths.len(),
251                details.payload_digest
252            );
253            Ok(KeysetLoadOutcome::Verified(details))
254        }
255        Err(reason_string) => {
256            if require_trust_verify_keys {
257                return Err(anyhow::anyhow!(
258                    "CELLOS_TRUST_KEYSET_CHAIN_PATH: verification failed under CELLOS_REQUIRE_TRUST_VERIFY_KEYS=1: {reason_string}"
259                ));
260            }
261            tracing::warn!(
262                target: "cellos.supervisor.trust",
263                attempted_keyset_basename = %head_basename,
264                envelope_count = paths.len(),
265                reason = %reason_string,
266                "signed trust keyset chain verification failed; continuing in degraded mode"
267            );
268            Ok(KeysetLoadOutcome::Failed(KeysetVerificationFailedDetails {
269                attempted_keyset_basename: head_basename,
270                reason: reason_string,
271            }))
272        }
273    }
274}
275
276/// Internal: parse every envelope file in `paths`, run the chain verifier,
277/// and surface the HEAD envelope's verification details on success.
278fn attempt_load_and_verify_chain(
279    paths: &[PathBuf],
280    keys: &HashMap<String, VerifyingKey>,
281    now: std::time::SystemTime,
282) -> Result<KeysetVerifiedDetails, String> {
283    let mut chain: Vec<SignedTrustKeysetEnvelope> = Vec::with_capacity(paths.len());
284    for path in paths {
285        let raw =
286            read_envelope_file(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
287        let envelope: SignedTrustKeysetEnvelope = serde_json::from_str(&raw)
288            .map_err(|e| format!("JSON parse error in {}: {e}", path.display()))?;
289        chain.push(envelope);
290    }
291
292    let head_payload_bytes =
293        verify_signed_trust_keyset_chain(&chain, keys, now).map_err(|e| format!("{e}"))?;
294
295    // The chain verifier already proved each envelope (including HEAD)
296    // verifies; we re-walk HEAD's signatures only to surface the winning kid
297    // for the event, mirroring the single-envelope path.
298    let head_envelope = chain.last().expect("chain non-empty");
299    let verified_signer_kid =
300        pick_verified_signer_kid(head_envelope, &head_payload_bytes, keys, now)
301            .unwrap_or_else(|| "(unknown)".to_string());
302    let keyset_id =
303        decode_inner_keyset_id(&head_payload_bytes).unwrap_or_else(|| "(unknown)".into());
304
305    Ok(KeysetVerifiedDetails {
306        keyset_id,
307        payload_digest: head_envelope.payload_digest.clone(),
308        verified_signer_kid,
309    })
310}
311
312/// Internal: open + parse + verify an envelope at `path`. Returns the
313/// `KeysetVerifiedDetails` on success or a stringified reason on any failure.
314fn attempt_load_and_verify(
315    path: &Path,
316    keys: &HashMap<String, VerifyingKey>,
317    now: std::time::SystemTime,
318) -> Result<KeysetVerifiedDetails, String> {
319    let raw =
320        read_envelope_file(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
321
322    let envelope: SignedTrustKeysetEnvelope = serde_json::from_str(&raw)
323        .map_err(|e| format!("JSON parse error in {}: {e}", path.display()))?;
324
325    let payload_bytes =
326        verify_signed_trust_keyset_envelope(&envelope, keys, now).map_err(|e| format!("{e}"))?;
327
328    // Find which signature actually verified — we need to surface it in the
329    // event. The verifier returns the raw payload bytes on success but does
330    // not tell us the kid that won; we re-walk the signatures here using the
331    // same predicate the verifier did.
332    let verified_signer_kid = pick_verified_signer_kid(&envelope, &payload_bytes, keys, now)
333        .unwrap_or_else(|| "(unknown)".to_string());
334
335    let keyset_id = decode_inner_keyset_id(&payload_bytes).unwrap_or_else(|| "(unknown)".into());
336
337    Ok(KeysetVerifiedDetails {
338        keyset_id,
339        payload_digest: envelope.payload_digest.clone(),
340        verified_signer_kid,
341    })
342}
343
344/// Re-walk the envelope signatures to find the kid whose signature verified.
345///
346/// Mirrors the verifier's per-signature loop in
347/// `cellos_core::verify_signed_trust_keyset_envelope`: ed25519 algorithm,
348/// known kid, optional `notBefore`/`notAfter` window check (delegated to the
349/// dalek primitive for now — the dalek `Signature::from_bytes` accepts any
350/// 64-byte input), `verify_strict` over the raw payload bytes.
351fn pick_verified_signer_kid(
352    envelope: &SignedTrustKeysetEnvelope,
353    payload_bytes: &[u8],
354    keys: &HashMap<String, VerifyingKey>,
355    now: std::time::SystemTime,
356) -> Option<String> {
357    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
358    use base64::Engine as _;
359    use chrono::DateTime;
360    use ed25519_dalek::Signature;
361
362    for sig in &envelope.signatures {
363        if sig.algorithm != "ed25519" {
364            continue;
365        }
366        let Some(verifying_key) = keys.get(&sig.signer_kid) else {
367            continue;
368        };
369        if !window_contains(now, sig.not_before.as_deref(), sig.not_after.as_deref()) {
370            continue;
371        }
372        let sig_b64 = sig.signature.trim_end_matches('=');
373        let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(sig_b64) else {
374            continue;
375        };
376        let Ok(sig_array) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
377            continue;
378        };
379        let signature = Signature::from_bytes(&sig_array);
380        if verifying_key
381            .verify_strict(payload_bytes, &signature)
382            .is_ok()
383        {
384            return Some(sig.signer_kid.clone());
385        }
386    }
387    // Suppress unused-import warning when neither path is exercised.
388    let _ = DateTime::<chrono::Utc>::from_timestamp(0, 0);
389    None
390}
391
392/// Mirrors `cellos_core::spec_validation::signature_window_contains` (private).
393fn window_contains(
394    now: std::time::SystemTime,
395    not_before: Option<&str>,
396    not_after: Option<&str>,
397) -> bool {
398    use chrono::DateTime;
399    let now_chrono: DateTime<chrono::Utc> = now.into();
400    if let Some(nb) = not_before {
401        match DateTime::parse_from_rfc3339(nb) {
402            Ok(t) => {
403                if now_chrono < t.with_timezone(&chrono::Utc) {
404                    return false;
405                }
406            }
407            Err(_) => return false,
408        }
409    }
410    if let Some(na) = not_after {
411        match DateTime::parse_from_rfc3339(na) {
412            Ok(t) => {
413                if now_chrono > t.with_timezone(&chrono::Utc) {
414                    return false;
415                }
416            }
417            Err(_) => return false,
418        }
419    }
420    true
421}
422
423/// Pull the inner keyset's `keysetId` out of the raw payload bytes via a
424/// minimal `{ "keysetId": "..." }` parse — full schema validation is the
425/// trustd sibling repo's job.
426fn decode_inner_keyset_id(payload_bytes: &[u8]) -> Option<String> {
427    let v: Value = serde_json::from_slice(payload_bytes).ok()?;
428    v.as_object()?.get("keysetId")?.as_str().map(String::from)
429}
430
431/// Reads `path` with `O_NOFOLLOW` on Unix (matching `composition.rs`'s
432/// `CELLOS_POLICY_PACK_PATH` / `CELLOS_AUTHORITY_KEYS_PATH` policy — SEC-15b).
433fn read_envelope_file(path: &Path) -> Result<String, std::io::Error> {
434    #[cfg(unix)]
435    {
436        use std::io::Read;
437        use std::os::unix::fs::OpenOptionsExt;
438        let mut opts = std::fs::OpenOptions::new();
439        opts.read(true);
440        opts.custom_flags(libc::O_RDONLY | libc::O_NOFOLLOW);
441        let mut file = opts.open(path)?;
442        let mut buf = String::new();
443        file.read_to_string(&mut buf)?;
444        Ok(buf)
445    }
446    #[cfg(not(unix))]
447    {
448        std::fs::read_to_string(path)
449    }
450}
451
452/// Convenience: emit the [`KeysetLoadOutcome`] as a single CloudEvent through
453/// `event_sink`. No-op for `KeysetLoadOutcome::NotConfigured`.
454///
455/// `now` is the timestamp embedded in the `verifiedAt` / `failedAt` field —
456/// passed in for testability (unit tests pin it to a known value).
457///
458/// Errors from sink emit are surfaced as `anyhow::Error`. Phase 2 callers
459/// today pass `?` — sink failures during startup-time keyset establishment
460/// are operator-visible (a misbehaving JetStream connection still surfaces).
461pub async fn emit_keyset_outcome(
462    outcome: &KeysetLoadOutcome,
463    event_sink: &Arc<dyn EventSink>,
464    jsonl_sink: Option<&Arc<dyn EventSink>>,
465    now: chrono::DateTime<chrono::Utc>,
466) -> Result<(), anyhow::Error> {
467    let timestamp = now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
468    match outcome {
469        KeysetLoadOutcome::NotConfigured => Ok(()),
470        KeysetLoadOutcome::Verified(details) => {
471            let envelope = cloud_event_v1_keyset_verified(
472                "cellos-supervisor",
473                &timestamp,
474                &details.keyset_id,
475                &details.payload_digest,
476                &details.verified_signer_kid,
477                &timestamp,
478                None,
479            )?;
480            event_sink
481                .emit(&envelope)
482                .await
483                .map_err(|e| anyhow::anyhow!("emit keyset_verified: {e}"))?;
484            if let Some(secondary) = jsonl_sink {
485                secondary
486                    .emit(&envelope)
487                    .await
488                    .map_err(|e| anyhow::anyhow!("emit keyset_verified to jsonl sink: {e}"))?;
489            }
490            Ok(())
491        }
492        KeysetLoadOutcome::Failed(details) => {
493            let envelope = cloud_event_v1_keyset_verification_failed(
494                "cellos-supervisor",
495                &timestamp,
496                &details.attempted_keyset_basename,
497                &details.reason,
498                &timestamp,
499                None,
500            )?;
501            event_sink
502                .emit(&envelope)
503                .await
504                .map_err(|e| anyhow::anyhow!("emit keyset_verification_failed: {e}"))?;
505            if let Some(secondary) = jsonl_sink {
506                secondary.emit(&envelope).await.map_err(|e| {
507                    anyhow::anyhow!("emit keyset_verification_failed to jsonl sink: {e}")
508                })?;
509            }
510            Ok(())
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    //! Unit-level smoke tests for the env-driven loaders. Full integration
518    //! coverage (env-flag gating, event emission through a real sink) lives in
519    //! `tests/supervisor_trust_keyset_verify.rs`.
520    use super::{
521        load_and_verify_trust_keyset_from_env, load_trust_verify_keys_from_env, KeysetLoadOutcome,
522    };
523    use std::sync::{Mutex, MutexGuard};
524
525    static ENV_MUTEX: Mutex<()> = Mutex::new(());
526
527    fn lock_env() -> MutexGuard<'static, ()> {
528        ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
529    }
530
531    #[test]
532    fn unset_path_with_require_unset_yields_empty_map() {
533        let _guard = lock_env();
534        std::env::remove_var("CELLOS_TRUST_VERIFY_KEYS_PATH");
535        let keys = load_trust_verify_keys_from_env(false).expect("legacy posture");
536        assert!(keys.is_empty());
537    }
538
539    #[test]
540    fn unset_path_with_require_set_errors() {
541        let _guard = lock_env();
542        std::env::remove_var("CELLOS_TRUST_VERIFY_KEYS_PATH");
543        let err = load_trust_verify_keys_from_env(true).expect_err("require set + unset path");
544        assert!(format!("{err}").contains("CELLOS_TRUST_VERIFY_KEYS_PATH"));
545    }
546
547    #[test]
548    fn keyset_path_unset_returns_not_configured() {
549        let _guard = lock_env();
550        std::env::remove_var("CELLOS_TRUST_KEYSET_PATH");
551        let outcome = load_and_verify_trust_keyset_from_env(
552            &Default::default(),
553            false,
554            std::time::SystemTime::now(),
555        )
556        .expect("unset path + fail-open");
557        assert!(matches!(outcome, KeysetLoadOutcome::NotConfigured));
558    }
559}