Skip to main content

cellos_projector/
event_decode.rs

1//! I5 — per-event signing decode/verify shim shared by the JSONL projector
2//! and the JetStream `cellos-state-server` consumer.
3//!
4//! Doctrine: D1 — verification is OPT-IN. When no verification path is
5//! configured, the consumer accepts both raw `CloudEventV1` lines (the
6//! historical wire format) and unsigned `SignedEventEnvelopeV1` wrappers
7//! transparently. When `CELLOS_EVENT_VERIFY_KEYS_PATH` is set, every signed
8//! envelope MUST verify; raw `CloudEventV1` is still accepted unless
9//! `CELLOS_EVENT_REQUIRE_SIGNED=1` flips the policy to require-signed.
10//!
11//! Key file format mirrors `cellos_core::parse_trust_verify_keys` —
12//! a JSON object mapping signer kid → base64url-encoded 32-byte Ed25519
13//! verifying key. HMAC verification is supported through the underlying
14//! [`cellos_core::verify_signed_event_envelope`] but no env hook is wired
15//! here yet (cellos-supervisor's emit path only signs Ed25519 today).
16
17use std::collections::HashMap;
18use std::path::PathBuf;
19use std::sync::Arc;
20
21use anyhow::{anyhow, Result};
22use cellos_core::{
23    load_trust_verify_keys_file, verify_signed_event_envelope, CloudEventV1, SignedEventEnvelopeV1,
24};
25use ed25519_dalek::VerifyingKey;
26
27/// Verification configuration sourced once at startup.
28///
29/// Cheap to clone (`Arc`-wrapped keyring); pass into per-event hot loops.
30#[derive(Clone, Default)]
31pub struct EventVerifierConfig {
32    /// `signer_kid → VerifyingKey` map. Empty when no verification key file
33    /// is configured.
34    pub verifying_keys: Arc<HashMap<String, VerifyingKey>>,
35    /// HMAC keys placeholder — currently always empty (supervisor signs
36    /// Ed25519 only). Wired so the [`verify_signed_event_envelope`] call
37    /// surface stays algorithm-agnostic.
38    pub hmac_keys: Arc<HashMap<String, Vec<u8>>>,
39    /// When true, raw `CloudEventV1` payloads (no signature wrapper) are
40    /// rejected. Mirrors `CELLOS_EVENT_REQUIRE_SIGNED=1`.
41    pub require_signed: bool,
42}
43
44impl EventVerifierConfig {
45    /// Read configuration from process environment.
46    ///
47    /// Variables:
48    /// - `CELLOS_EVENT_VERIFY_KEYS_PATH`: JSON keyring (kid → base64url
49    ///   ed25519 pubkey). Loaded with O_NOFOLLOW on Unix (mirrors SEC-15b).
50    /// - `CELLOS_EVENT_REQUIRE_SIGNED`: `1`/`true`/`yes`/`on` → require every
51    ///   event to arrive in a signed envelope.
52    ///
53    /// Returns `Ok(default)` when no key file is configured. Returns an error
54    /// when the file is configured but cannot be loaded — fail-closed so a
55    /// misconfigured operator does not silently accept unsigned traffic.
56    pub fn from_env() -> Result<Self> {
57        let require_signed = std::env::var("CELLOS_EVENT_REQUIRE_SIGNED")
58            .map(|v| {
59                let t = v.trim().to_ascii_lowercase();
60                matches!(t.as_str(), "1" | "true" | "yes" | "on")
61            })
62            .unwrap_or(false);
63
64        let verifying_keys = match std::env::var("CELLOS_EVENT_VERIFY_KEYS_PATH") {
65            Ok(p) if !p.trim().is_empty() => {
66                let path = PathBuf::from(p.trim());
67                let keys = load_trust_verify_keys_file(&path).map_err(|e| {
68                    anyhow!(
69                        "CELLOS_EVENT_VERIFY_KEYS_PATH={}: load failed: {e}",
70                        path.display()
71                    )
72                })?;
73                Arc::new(keys)
74            }
75            _ => Arc::new(HashMap::new()),
76        };
77
78        Ok(Self {
79            verifying_keys,
80            hmac_keys: Arc::new(HashMap::new()),
81            require_signed,
82        })
83    }
84
85    /// Have any keys been loaded?
86    pub fn has_keys(&self) -> bool {
87        !self.verifying_keys.is_empty() || !self.hmac_keys.is_empty()
88    }
89}
90
91/// Decode a single payload (JSON-encoded JetStream message body or one JSONL
92/// line). Tries the signed-envelope shape first; on miss, falls back to the
93/// raw `CloudEventV1` shape — preserving historical wire-format compatibility.
94///
95/// Verification gates:
96/// - Signed envelope + `cfg.has_keys()` → MUST verify successfully.
97/// - Signed envelope + no keys → accepted (the operator chose not to verify).
98/// - Raw event + `cfg.require_signed` → rejected.
99/// - Raw event + permissive → accepted as-is.
100pub fn decode_event(bytes: &[u8], cfg: &EventVerifierConfig) -> Result<CloudEventV1> {
101    // First try the signed envelope (the field set is a strict superset of a
102    // raw CloudEvent, so we use field-level discrimination via try-parse).
103    if let Ok(envelope) = serde_json::from_slice::<SignedEventEnvelopeV1>(bytes) {
104        // Heuristic: a real signed envelope has a non-empty signature and
105        // algorithm. A raw CloudEventV1 does not — but `SignedEventEnvelopeV1`
106        // serde requires `signerKid`/`algorithm`/`signature`, so a raw event
107        // would fail to parse here. This branch is unambiguous.
108        if cfg.has_keys() {
109            verify_signed_event_envelope(&envelope, &cfg.verifying_keys, &cfg.hmac_keys)
110                .map_err(|e| anyhow!("signed event envelope verify failed: {e}"))?;
111        }
112        return Ok(envelope.event);
113    }
114
115    // Fallback: treat as a raw CloudEvent.
116    if cfg.require_signed {
117        return Err(anyhow!(
118            "CELLOS_EVENT_REQUIRE_SIGNED=1 but payload is not a SignedEventEnvelopeV1"
119        ));
120    }
121    let event: CloudEventV1 = serde_json::from_slice(bytes)
122        .map_err(|e| anyhow!("payload is neither a signed envelope nor a CloudEventV1: {e}"))?;
123    Ok(event)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use cellos_core::sign_event_ed25519;
130    use ed25519_dalek::SigningKey;
131
132    fn signing_key(seed: u8) -> SigningKey {
133        SigningKey::from_bytes(&[seed; 32])
134    }
135
136    fn sample_event(id: &str) -> CloudEventV1 {
137        CloudEventV1 {
138            specversion: "1.0".into(),
139            id: id.into(),
140            source: "/test".into(),
141            ty: "dev.cellos.events.cell.lifecycle.v1.started".into(),
142            datacontenttype: Some("application/json".into()),
143            data: Some(serde_json::json!({"cellId": "c-1", "specId": "s-1"})),
144            time: None,
145            traceparent: None,
146        }
147    }
148
149    #[test]
150    fn raw_event_accepted_in_permissive_mode() {
151        let cfg = EventVerifierConfig::default();
152        let event = sample_event("e1");
153        let bytes = serde_json::to_vec(&event).unwrap();
154        let decoded = decode_event(&bytes, &cfg).expect("permissive accepts raw");
155        assert_eq!(decoded.id, "e1");
156    }
157
158    #[test]
159    fn raw_event_rejected_when_require_signed() {
160        let cfg = EventVerifierConfig {
161            require_signed: true,
162            ..Default::default()
163        };
164        let event = sample_event("e1");
165        let bytes = serde_json::to_vec(&event).unwrap();
166        let err = decode_event(&bytes, &cfg).expect_err("require_signed rejects raw");
167        assert!(format!("{err}").contains("REQUIRE_SIGNED"));
168    }
169
170    #[test]
171    fn signed_envelope_verifies_against_keyring() {
172        let signer = signing_key(7);
173        let event = sample_event("e1");
174        let envelope = sign_event_ed25519(&event, "kid-7", &signer).unwrap();
175        let bytes = serde_json::to_vec(&envelope).unwrap();
176
177        let mut keys = HashMap::new();
178        keys.insert("kid-7".to_string(), signer.verifying_key());
179        let cfg = EventVerifierConfig {
180            verifying_keys: Arc::new(keys),
181            hmac_keys: Arc::new(HashMap::new()),
182            require_signed: true,
183        };
184
185        let decoded = decode_event(&bytes, &cfg).expect("verifies");
186        assert_eq!(decoded.id, "e1");
187    }
188
189    #[test]
190    fn signed_envelope_with_tampered_event_rejected() {
191        let signer = signing_key(7);
192        let event = sample_event("e1");
193        let mut envelope = sign_event_ed25519(&event, "kid-7", &signer).unwrap();
194        envelope.event.id = "tampered".into();
195        let bytes = serde_json::to_vec(&envelope).unwrap();
196
197        let mut keys = HashMap::new();
198        keys.insert("kid-7".to_string(), signer.verifying_key());
199        let cfg = EventVerifierConfig {
200            verifying_keys: Arc::new(keys),
201            hmac_keys: Arc::new(HashMap::new()),
202            require_signed: false,
203        };
204
205        let err = decode_event(&bytes, &cfg).expect_err("tampered must be rejected");
206        assert!(format!("{err}").contains("verify failed"));
207    }
208
209    #[test]
210    fn signed_envelope_unknown_kid_rejected() {
211        let signer = signing_key(7);
212        let event = sample_event("e1");
213        let envelope = sign_event_ed25519(&event, "kid-unknown", &signer).unwrap();
214        let bytes = serde_json::to_vec(&envelope).unwrap();
215
216        let mut keys = HashMap::new();
217        keys.insert("kid-other".to_string(), signing_key(11).verifying_key());
218        let cfg = EventVerifierConfig {
219            verifying_keys: Arc::new(keys),
220            hmac_keys: Arc::new(HashMap::new()),
221            require_signed: false,
222        };
223
224        let err = decode_event(&bytes, &cfg).expect_err("unknown kid must be rejected");
225        let msg = format!("{err}");
226        assert!(
227            msg.contains("verify failed") || msg.contains("unknown"),
228            "got: {msg}"
229        );
230    }
231
232    #[test]
233    fn signed_envelope_accepted_without_keyring() {
234        // No keys configured → the verifier acts as a transparent unwrapper.
235        let signer = signing_key(7);
236        let event = sample_event("e1");
237        let envelope = sign_event_ed25519(&event, "kid-7", &signer).unwrap();
238        let bytes = serde_json::to_vec(&envelope).unwrap();
239
240        let cfg = EventVerifierConfig::default();
241        let decoded = decode_event(&bytes, &cfg).expect("no-keys is transparent unwrap");
242        assert_eq!(decoded.id, "e1");
243    }
244}