Skip to main content

cellos_host_telemetry/
sign_outbound.rs

1//! F4b — supervisor-side signing of outbound guest-derived envelopes.
2//!
3//! Doctrine (ADR-0006 §5, channel-authenticity model):
4//!
5//! - The guest agent does NOT hold a signing key. It declares fields over
6//!   vsock; the supervisor host-stamps the non-negotiable attribution fields
7//!   ([`HostStamp`]) and signs the resulting [`CloudEventV1`] envelope.
8//! - Signing payload is the canonical-JSON serialization of the FULL
9//!   `CloudEventV1` (as established by I5 — `cellos_core::trust_keys::
10//!   canonical_event_signing_payload`). Mutating any field after the signature
11//!   is computed MUST cause verification to fail (O2 — observability that
12//!   cannot be quietly retconned).
13//!
14//! ## Signing key material
15//!
16//! [`SigningKeyMaterial`] is the runtime knob. Three modes:
17//!
18//! - [`SigningKeyMaterial::Off`] — passthrough; outbound envelopes are emitted
19//!   un-signed. This is the default when no env vars are set, matching the
20//!   I5 doctrine that signing is OPT-IN.
21//! - [`SigningKeyMaterial::Hmac`] — shared symmetric key (HMAC-SHA256, FIPS
22//!   198). Verifier needs the same shared key in its `hmac_keys` map.
23//! - [`SigningKeyMaterial::Ed25519`] — asymmetric; verifier needs only the
24//!   public key in its `verifying_keys` map.
25//!
26//! Loading is from environment variables (the supervisor crate is the
27//! consumer; this module is location-agnostic so any future host-side wiring
28//! can call [`SigningKeyMaterial::from_env`] without touching the supervisor).
29//!
30//! ### Env vars
31//!
32//! - `CELLOS_HOST_TELEMETRY_SIGN_ALG` — `"off"` (default), `"hmac-sha256"`,
33//!   or `"ed25519"`.
34//! - `CELLOS_HOST_TELEMETRY_SIGN_KID` — required when alg != off. The signer
35//!   kid embedded in the [`SignedEventEnvelopeV1`].
36//! - `CELLOS_HOST_TELEMETRY_SIGN_HMAC_KEY` — base64url (no-pad, padding
37//!   tolerated) of the shared HMAC key. Required when alg=hmac-sha256.
38//! - `CELLOS_HOST_TELEMETRY_SIGN_ED25519_SK` — base64url of the 32-byte
39//!   Ed25519 seed. Required when alg=ed25519.
40//!
41//! Setting both `*_HMAC_KEY` and `*_ED25519_SK` is rejected: the operator
42//! must pick one to avoid ambiguity over which key signed the stream.
43//!
44//! ## F3b interlock
45//!
46//! [`StampedDeclaration`] is defined locally as a `{guest, host}` pair so
47//! F4b can be merged before F3b. When F3b lands, lift this struct to
48//! `cellos-host-telemetry::lib` (or its own module); F4b's signer accepts it
49//! by value/by-reference and is location-agnostic.
50
51use std::env;
52use std::time::UNIX_EPOCH;
53
54use base64::engine::general_purpose::URL_SAFE_NO_PAD;
55use base64::Engine as _;
56use ed25519_dalek::SigningKey;
57use zeroize::Zeroizing;
58
59use cellos_core::trust_keys::{sign_event_ed25519, sign_event_hmac_sha256, SignedEventEnvelopeV1};
60use cellos_core::CloudEventV1;
61
62use crate::{GuestDeclaration, HostStamp};
63
64// ── F3b interlock ──────────────────────────────────────────────────────────
65
66/// Pair of guest declaration + host stamp.
67///
68/// **F3b note:** F4b ships this struct here so the signer doesn't depend on
69/// F3b having landed first. When F3b lands it should hoist this definition to
70/// `cellos-host-telemetry::lib` (or a sibling `stamped` module) and re-export
71/// it; the signer below stays unchanged because it consumes the inputs by
72/// reference. This is the F3b/F4b boundary contract.
73#[derive(Debug, Clone)]
74pub struct StampedDeclaration {
75    /// What the guest agent declared over vsock.
76    pub guest: GuestDeclaration,
77    /// What the supervisor stamped on receive (overrides anything the guest
78    /// claimed about cell_id / run_id / timestamp / spec hash).
79    pub host: HostStamp,
80}
81
82// ── Provenance constant (events::Provenance is a struct, not the enum we
83//    want here) ──────────────────────────────────────────────────────────────
84
85/// CloudEvent extension/data marker for "this fact was declared by the guest
86/// agent, not directly observed host-side."
87///
88/// `cellos_core::events::Provenance` is a struct
89/// `{ parent, parent_type }` describing parent-event lineage — not an
90/// epistemic-status enum. The canonical `Declared` / `Observed` distinction
91/// lives in `cellos_core::authority::EpistemicStatus`. F4b only needs the
92/// literal token; we hard-code it here to avoid pulling the authority
93/// surface into the host-telemetry crate.
94pub const PROVENANCE_DECLARED: &str = "declared";
95
96// ── Signing key material ───────────────────────────────────────────────────
97
98/// Env var: signing algorithm for outbound CloudEvents.
99pub const ENV_SIGN_ALG: &str = "CELLOS_HOST_TELEMETRY_SIGN_ALG";
100/// Env var: signer kid embedded in the envelope.
101pub const ENV_SIGN_KID: &str = "CELLOS_HOST_TELEMETRY_SIGN_KID";
102/// Env var: HMAC-SHA256 shared key (base64url).
103pub const ENV_SIGN_HMAC_KEY: &str = "CELLOS_HOST_TELEMETRY_SIGN_HMAC_KEY";
104/// Env var: Ed25519 32-byte seed (base64url).
105pub const ENV_SIGN_ED25519_SK: &str = "CELLOS_HOST_TELEMETRY_SIGN_ED25519_SK";
106
107/// Errors returned by the F4b signer.
108#[derive(Debug, thiserror::Error)]
109pub enum SignOutboundError {
110    /// Env-driven configuration was internally inconsistent (e.g. both HMAC
111    /// and Ed25519 keys set, or alg set without kid).
112    #[error("invalid signing config: {0}")]
113    InvalidConfig(String),
114
115    /// Underlying signer rejected the canonical payload.
116    #[error("signer error: {0}")]
117    Signer(String),
118
119    /// Canonical-JSON serialization failed.
120    #[error("serialize error: {0}")]
121    Serialize(String),
122}
123
124/// Runtime signing key material.
125///
126/// Note: `Debug` is implemented manually so that key bytes never leak into
127/// log output. We surface the variant + kid only.
128pub enum SigningKeyMaterial {
129    /// Signing disabled — passthrough, no envelope wrapping.
130    Off,
131    /// HMAC-SHA256 with a shared symmetric key.
132    Hmac {
133        /// Signer kid embedded in the [`SignedEventEnvelopeV1`].
134        kid: String,
135        /// Shared HMAC key bytes. Red-team wave-2 T1b: `Zeroizing<Vec<u8>>`
136        /// so the secret key is wiped on drop instead of lingering in freed
137        /// allocations.
138        key: Zeroizing<Vec<u8>>,
139    },
140    /// Ed25519 asymmetric signing.
141    Ed25519 {
142        /// Signer kid embedded in the [`SignedEventEnvelopeV1`].
143        kid: String,
144        /// Ed25519 signing key (private). Verifier holds the matching
145        /// `VerifyingKey`.
146        signing_key: SigningKey,
147    },
148}
149
150impl std::fmt::Debug for SigningKeyMaterial {
151    /// Custom `Debug` that NEVER prints key bytes — only variant + kid. This
152    /// matters because operators sometimes pipe `tracing` output into log
153    /// stores that retain forever; an accidental `{:?}` of a `SigningKeyMaterial`
154    /// must not become a key-leak channel.
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            SigningKeyMaterial::Off => f.debug_struct("SigningKeyMaterial::Off").finish(),
158            SigningKeyMaterial::Hmac { kid, key } => f
159                .debug_struct("SigningKeyMaterial::Hmac")
160                .field("kid", kid)
161                .field(
162                    "key",
163                    &format_args!("<redacted {}-byte hmac key>", key.len()),
164                )
165                .finish(),
166            SigningKeyMaterial::Ed25519 { kid, .. } => f
167                .debug_struct("SigningKeyMaterial::Ed25519")
168                .field("kid", kid)
169                .field("signing_key", &"<redacted ed25519 signing key>")
170                .finish(),
171        }
172    }
173}
174
175impl SigningKeyMaterial {
176    /// True iff this is the [`SigningKeyMaterial::Off`] variant.
177    pub fn is_off(&self) -> bool {
178        matches!(self, SigningKeyMaterial::Off)
179    }
180
181    /// Signer kid for the active variant; `None` for [`SigningKeyMaterial::Off`].
182    pub fn kid(&self) -> Option<&str> {
183        match self {
184            SigningKeyMaterial::Off => None,
185            SigningKeyMaterial::Hmac { kid, .. } => Some(kid.as_str()),
186            SigningKeyMaterial::Ed25519 { kid, .. } => Some(kid.as_str()),
187        }
188    }
189
190    /// Load signing material from env vars (see module docs).
191    ///
192    /// - Unset / `"off"` → [`SigningKeyMaterial::Off`].
193    /// - `"hmac-sha256"` → requires kid + HMAC key, rejects ed25519 key.
194    /// - `"ed25519"` → requires kid + ed25519 seed, rejects HMAC key.
195    pub fn from_env() -> Result<Self, SignOutboundError> {
196        let alg_raw = env::var(ENV_SIGN_ALG).unwrap_or_default();
197        let alg = alg_raw.trim().to_ascii_lowercase();
198
199        if alg.is_empty() || alg == "off" {
200            // Even in Off mode, refuse to silently swallow stray key material.
201            // Operator most likely meant to enable signing; surface the
202            // misconfig instead of pretending Off.
203            if env::var(ENV_SIGN_HMAC_KEY).is_ok() || env::var(ENV_SIGN_ED25519_SK).is_ok() {
204                return Err(SignOutboundError::InvalidConfig(format!(
205                    "{ENV_SIGN_ALG} is off (or unset) but {ENV_SIGN_HMAC_KEY} \
206                     or {ENV_SIGN_ED25519_SK} is set — refuse to silently \
207                     drop key material; set {ENV_SIGN_ALG} explicitly"
208                )));
209            }
210            return Ok(SigningKeyMaterial::Off);
211        }
212
213        let kid = env::var(ENV_SIGN_KID).map_err(|_| {
214            SignOutboundError::InvalidConfig(format!(
215                "{ENV_SIGN_ALG}={alg_raw:?} requires {ENV_SIGN_KID} to be set"
216            ))
217        })?;
218        if kid.trim().is_empty() {
219            return Err(SignOutboundError::InvalidConfig(format!(
220                "{ENV_SIGN_KID} must be a non-empty signer kid"
221            )));
222        }
223
224        let hmac_set = env::var(ENV_SIGN_HMAC_KEY).is_ok();
225        let ed_set = env::var(ENV_SIGN_ED25519_SK).is_ok();
226        if hmac_set && ed_set {
227            return Err(SignOutboundError::InvalidConfig(format!(
228                "mutual-exclusion violated: both {ENV_SIGN_HMAC_KEY} and \
229                 {ENV_SIGN_ED25519_SK} are set — pick exactly one"
230            )));
231        }
232
233        match alg.as_str() {
234            "hmac-sha256" | "hmac" => {
235                let key_b64 = env::var(ENV_SIGN_HMAC_KEY).map_err(|_| {
236                    SignOutboundError::InvalidConfig(format!(
237                        "{ENV_SIGN_ALG}=hmac-sha256 requires {ENV_SIGN_HMAC_KEY}"
238                    ))
239                })?;
240                let trimmed = key_b64.trim().trim_end_matches('=');
241                // T1b: wrap decoded key in Zeroizing immediately so it wipes
242                // on the is_empty() error path too.
243                let key: Zeroizing<Vec<u8>> =
244                    Zeroizing::new(URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
245                        SignOutboundError::InvalidConfig(format!(
246                            "{ENV_SIGN_HMAC_KEY} is not valid base64url: {e}"
247                        ))
248                    })?);
249                if key.is_empty() {
250                    return Err(SignOutboundError::InvalidConfig(format!(
251                        "{ENV_SIGN_HMAC_KEY} decoded to zero bytes"
252                    )));
253                }
254                Ok(SigningKeyMaterial::Hmac { kid, key })
255            }
256            "ed25519" => {
257                let sk_b64 = env::var(ENV_SIGN_ED25519_SK).map_err(|_| {
258                    SignOutboundError::InvalidConfig(format!(
259                        "{ENV_SIGN_ALG}=ed25519 requires {ENV_SIGN_ED25519_SK}"
260                    ))
261                })?;
262                let trimmed = sk_b64.trim().trim_end_matches('=');
263                let bytes = URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
264                    SignOutboundError::InvalidConfig(format!(
265                        "{ENV_SIGN_ED25519_SK} is not valid base64url: {e}"
266                    ))
267                })?;
268                let seed: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
269                    SignOutboundError::InvalidConfig(format!(
270                        "{ENV_SIGN_ED25519_SK} decoded to {} bytes, expected 32",
271                        bytes.len()
272                    ))
273                })?;
274                let signing_key = SigningKey::from_bytes(&seed);
275                Ok(SigningKeyMaterial::Ed25519 { kid, signing_key })
276            }
277            other => Err(SignOutboundError::InvalidConfig(format!(
278                "unknown {ENV_SIGN_ALG}={other:?} (expected off, hmac-sha256, or ed25519)"
279            ))),
280        }
281    }
282}
283
284// ── Outbound envelope construction ────────────────────────────────────────
285
286/// Outcome of [`host_stamp_and_sign`]: either an unsigned passthrough or a
287/// signed envelope, depending on the active [`SigningKeyMaterial`].
288#[derive(Debug, Clone)]
289pub enum SigningOutcome {
290    /// Signing was [`SigningKeyMaterial::Off`]; the bare envelope is emitted.
291    Unsigned(CloudEventV1),
292    /// Signing was active; the wrapped envelope is emitted.
293    Signed(SignedEventEnvelopeV1),
294}
295
296impl SigningOutcome {
297    /// Borrow the underlying CloudEvent regardless of variant.
298    pub fn event(&self) -> &CloudEventV1 {
299        match self {
300            SigningOutcome::Unsigned(ev) => ev,
301            SigningOutcome::Signed(env) => &env.event,
302        }
303    }
304}
305
306/// Build the host-stamped outbound `CloudEventV1` for a guest declaration.
307///
308/// The `data` payload contains both the guest-declared fields and the
309/// host-stamped attribution fields, with a `provenance` marker (literal
310/// `"declared"`) so consumers can distinguish guest-declared facts from
311/// host-observed ones.
312pub fn host_stamped_envelope(
313    stamped: &StampedDeclaration,
314    event_id: &str,
315    source: &str,
316    event_type: &str,
317) -> Result<CloudEventV1, SignOutboundError> {
318    let host_received_unix_secs = stamped
319        .host
320        .host_received_at
321        .duration_since(UNIX_EPOCH)
322        .map_err(|e| {
323            SignOutboundError::Serialize(format!("host_received_at predates UNIX_EPOCH: {e}"))
324        })?
325        .as_secs();
326    let host_received_at_rfc3339 = format_unix_secs_rfc3339(host_received_unix_secs);
327
328    let data = serde_json::json!({
329        "provenance": PROVENANCE_DECLARED,
330        "guest": {
331            "probeSource": stamped.guest.probe_source,
332            "guestPid": stamped.guest.guest_pid,
333            "guestComm": stamped.guest.guest_comm,
334            "guestMonotonicNs": stamped.guest.guest_monotonic_ns,
335        },
336        "host": {
337            "cellId": stamped.host.cell_id,
338            "runId": stamped.host.run_id,
339            "hostReceivedAt": host_received_at_rfc3339,
340            "specSignatureHash": stamped.host.spec_signature_hash,
341        },
342    });
343
344    Ok(CloudEventV1 {
345        specversion: "1.0".into(),
346        id: event_id.to_string(),
347        source: source.to_string(),
348        ty: event_type.to_string(),
349        datacontenttype: Some("application/json".into()),
350        data: Some(data),
351        time: Some(host_received_at_rfc3339),
352        traceparent: None,
353    })
354}
355
356/// Sign a host-stamped envelope under the active [`SigningKeyMaterial`].
357///
358/// Returns [`SigningOutcome::Unsigned`] when key material is
359/// [`SigningKeyMaterial::Off`], else [`SigningOutcome::Signed`].
360pub fn sign_host_stamped_envelope(
361    envelope: CloudEventV1,
362    material: &SigningKeyMaterial,
363) -> Result<SigningOutcome, SignOutboundError> {
364    match material {
365        SigningKeyMaterial::Off => Ok(SigningOutcome::Unsigned(envelope)),
366        SigningKeyMaterial::Hmac { kid, key } => {
367            let signed = sign_event_hmac_sha256(&envelope, kid, key)
368                .map_err(|e| SignOutboundError::Signer(format!("{e}")))?;
369            Ok(SigningOutcome::Signed(signed))
370        }
371        SigningKeyMaterial::Ed25519 { kid, signing_key } => {
372            let signed = sign_event_ed25519(&envelope, kid, signing_key)
373                .map_err(|e| SignOutboundError::Signer(format!("{e}")))?;
374            Ok(SigningOutcome::Signed(signed))
375        }
376    }
377}
378
379/// Composed: stamp + sign in one call. Most callers want this.
380pub fn host_stamp_and_sign(
381    stamped: &StampedDeclaration,
382    event_id: &str,
383    source: &str,
384    event_type: &str,
385    material: &SigningKeyMaterial,
386) -> Result<SigningOutcome, SignOutboundError> {
387    let envelope = host_stamped_envelope(stamped, event_id, source, event_type)?;
388    sign_host_stamped_envelope(envelope, material)
389}
390
391// ── Self-contained RFC3339 formatter ──────────────────────────────────────
392//
393// We deliberately do NOT pull `chrono` for a single timestamp. The
394// `host_received_at` field is `SystemTime`; for the outbound envelope we
395// emit RFC3339 / ISO-8601 in UTC at second resolution (matching the
396// `time` field convention used elsewhere in the codebase, e.g.
397// `cellos_core::events::cloud_event_v1_*`).
398
399/// Format `unix_secs` (seconds since UNIX epoch, UTC) as a Zulu-suffixed
400/// RFC3339 timestamp (`"YYYY-MM-DDTHH:MM:SSZ"`). This is the minimal
401/// formatter ADR-0006 needs; we avoid `chrono` because the supervisor's
402/// existing time emitters already use this exact shape.
403fn format_unix_secs_rfc3339(unix_secs: u64) -> String {
404    // Algorithm from Howard Hinnant's "date" library / chrono: a closed-form
405    // conversion from days-since-epoch to (year, month, day) using a shifted
406    // calendar where March is month 1. Tested against std::time / chrono in
407    // the unit tests below.
408    let secs_per_day: u64 = 86_400;
409    let days = (unix_secs / secs_per_day) as i64; // days since 1970-01-01
410    let secs_of_day = unix_secs % secs_per_day;
411    let hour = (secs_of_day / 3600) as u32;
412    let minute = ((secs_of_day % 3600) / 60) as u32;
413    let second = (secs_of_day % 60) as u32;
414
415    // Shift epoch from 1970-01-01 to 0000-03-01 (Hinnant's reference).
416    let z = days + 719_468;
417    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
418    let doe = (z - era * 146_097) as u64; // [0, 146096]
419    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
420    let y = yoe as i64 + era * 400;
421    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
422    let mp = (5 * doy + 2) / 153; // [0, 11]
423    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
424    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
425    let year = if m <= 2 { y + 1 } else { y };
426
427    format!(
428        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
429        year, m, d, hour, minute, second
430    )
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::collections::HashMap;
437    use std::sync::Mutex;
438    use std::time::Duration;
439
440    use cellos_core::trust_keys::verify_signed_event_envelope;
441
442    // Env-mutating tests are serialized via this module-level mutex so
443    // parallel cargo-test runs don't race on the process-global env table.
444    // Tests that don't touch env do NOT need to take the lock.
445    static ENV_MUTEX: Mutex<()> = Mutex::new(());
446
447    fn fixture_decl() -> StampedDeclaration {
448        StampedDeclaration {
449            guest: GuestDeclaration {
450                probe_source: "process_spawned".into(),
451                guest_pid: 4242,
452                guest_comm: "curl".into(),
453                guest_monotonic_ns: 1_234_567_890,
454            },
455            host: HostStamp {
456                cell_id: "cell-abc".into(),
457                run_id: "run-xyz".into(),
458                // Arbitrary fixed instant; only used to exercise stamping.
459                host_received_at: UNIX_EPOCH + Duration::from_secs(1_762_348_800),
460                spec_signature_hash: "sha256:deadbeef".into(),
461            },
462        }
463    }
464
465    fn signing_key(seed: u8) -> SigningKey {
466        SigningKey::from_bytes(&[seed; 32])
467    }
468
469    fn clear_sign_env() {
470        // SAFETY: process-wide env mutation is guarded by ENV_MUTEX.
471        std::env::remove_var(ENV_SIGN_ALG);
472        std::env::remove_var(ENV_SIGN_KID);
473        std::env::remove_var(ENV_SIGN_HMAC_KEY);
474        std::env::remove_var(ENV_SIGN_ED25519_SK);
475    }
476
477    // 1. HMAC round-trip ----------------------------------------------------
478
479    #[test]
480    fn hmac_round_trip_verifies_via_projector_path() {
481        let key = b"f4b-shared-symmetric-key-for-tests";
482        let material = SigningKeyMaterial::Hmac {
483            kid: "ops-host-telem-2026-q2".into(),
484            key: Zeroizing::new(key.to_vec()),
485        };
486        let outcome = host_stamp_and_sign(
487            &fixture_decl(),
488            "ev-hmac-001",
489            "/cellos-supervisor/host-telemetry",
490            "dev.cellos.events.cell.observability.guest.process_spawned",
491            &material,
492        )
493        .expect("sign ok");
494        let envelope = match outcome {
495            SigningOutcome::Signed(env) => env,
496            SigningOutcome::Unsigned(_) => panic!("expected Signed"),
497        };
498        assert_eq!(envelope.algorithm, "hmac-sha256");
499        assert_eq!(envelope.signer_kid, "ops-host-telem-2026-q2");
500
501        let verifying_keys: HashMap<String, _> = HashMap::new();
502        let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
503        hmac_keys.insert("ops-host-telem-2026-q2".into(), key.to_vec());
504        let verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
505            .expect("hmac round-trip must verify");
506        assert_eq!(verified.id, "ev-hmac-001");
507    }
508
509    // 2. Ed25519 round-trip -------------------------------------------------
510
511    #[test]
512    fn ed25519_round_trip_verifies_via_projector_path() {
513        let signer = signing_key(13);
514        let material = SigningKeyMaterial::Ed25519 {
515            kid: "ops-host-telem-ed-2026-q2".into(),
516            signing_key: signer.clone(),
517        };
518        let outcome = host_stamp_and_sign(
519            &fixture_decl(),
520            "ev-ed25519-001",
521            "/cellos-supervisor/host-telemetry",
522            "dev.cellos.events.cell.observability.guest.process_spawned",
523            &material,
524        )
525        .expect("sign ok");
526        let envelope = match outcome {
527            SigningOutcome::Signed(env) => env,
528            SigningOutcome::Unsigned(_) => panic!("expected Signed"),
529        };
530        assert_eq!(envelope.algorithm, "ed25519");
531
532        let mut verifying_keys: HashMap<String, _> = HashMap::new();
533        verifying_keys.insert("ops-host-telem-ed-2026-q2".into(), signer.verifying_key());
534        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
535        let verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
536            .expect("ed25519 round-trip must verify");
537        assert_eq!(verified.id, "ev-ed25519-001");
538    }
539
540    // 3. Mutation-post-sign rejected (O2 doctrine) -------------------------
541
542    #[test]
543    fn mutating_event_after_sign_breaks_verification() {
544        let signer = signing_key(17);
545        let material = SigningKeyMaterial::Ed25519 {
546            kid: "kid-mut".into(),
547            signing_key: signer.clone(),
548        };
549        let outcome = host_stamp_and_sign(
550            &fixture_decl(),
551            "ev-mut-001",
552            "/cellos-supervisor/host-telemetry",
553            "dev.cellos.events.cell.observability.guest.process_spawned",
554            &material,
555        )
556        .expect("sign ok");
557        let mut envelope = match outcome {
558            SigningOutcome::Signed(env) => env,
559            SigningOutcome::Unsigned(_) => panic!("expected Signed"),
560        };
561
562        // O2: tampering with the host-stamped attribution must invalidate
563        // the signature. Mutate the inner data.host.cellId.
564        if let Some(serde_json::Value::Object(map)) = envelope.event.data.as_mut() {
565            if let Some(serde_json::Value::Object(host)) = map.get_mut("host") {
566                host.insert(
567                    "cellId".into(),
568                    serde_json::Value::String("cell-EVIL".into()),
569                );
570            }
571        }
572
573        let mut verifying_keys: HashMap<String, _> = HashMap::new();
574        verifying_keys.insert("kid-mut".into(), signer.verifying_key());
575        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
576        let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
577            .expect_err("post-sign mutation must be rejected");
578        let msg = format!("{err}");
579        assert!(
580            msg.contains("ed25519 verify failed"),
581            "expected ed25519 verify failure, got: {msg}"
582        );
583    }
584
585    // 4. Off mode passthrough ----------------------------------------------
586
587    #[test]
588    fn off_mode_emits_unsigned_passthrough() {
589        let outcome = host_stamp_and_sign(
590            &fixture_decl(),
591            "ev-off-001",
592            "/cellos-supervisor/host-telemetry",
593            "dev.cellos.events.cell.observability.guest.process_spawned",
594            &SigningKeyMaterial::Off,
595        )
596        .expect("off-mode stamp ok");
597        match outcome {
598            SigningOutcome::Unsigned(ev) => {
599                assert_eq!(ev.id, "ev-off-001");
600                let data = ev.data.expect("data present");
601                assert_eq!(data["provenance"], "declared");
602                assert_eq!(data["host"]["cellId"], "cell-abc");
603            }
604            SigningOutcome::Signed(_) => panic!("Off must passthrough"),
605        }
606    }
607
608    // 5. Env load HMAC ------------------------------------------------------
609
610    #[test]
611    fn env_load_hmac_round_trips_a_signed_envelope() {
612        let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
613        clear_sign_env();
614        let key = b"env-hmac-key-for-from_env-test";
615        std::env::set_var(ENV_SIGN_ALG, "hmac-sha256");
616        std::env::set_var(ENV_SIGN_KID, "kid-from-env");
617        std::env::set_var(ENV_SIGN_HMAC_KEY, URL_SAFE_NO_PAD.encode(key));
618
619        let material = SigningKeyMaterial::from_env().expect("from_env hmac ok");
620        clear_sign_env();
621
622        assert!(!material.is_off());
623        assert_eq!(material.kid(), Some("kid-from-env"));
624
625        let outcome = host_stamp_and_sign(
626            &fixture_decl(),
627            "ev-env-hmac",
628            "/cellos-supervisor/host-telemetry",
629            "dev.cellos.events.cell.observability.guest.process_spawned",
630            &material,
631        )
632        .expect("sign ok");
633        let envelope = match outcome {
634            SigningOutcome::Signed(env) => env,
635            SigningOutcome::Unsigned(_) => panic!("expected Signed"),
636        };
637        assert_eq!(envelope.algorithm, "hmac-sha256");
638
639        let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
640        hmac_keys.insert("kid-from-env".into(), key.to_vec());
641        let verifying: HashMap<String, _> = HashMap::new();
642        verify_signed_event_envelope(&envelope, &verifying, &hmac_keys)
643            .expect("env-loaded hmac must verify");
644    }
645
646    // 6. Env load Off -------------------------------------------------------
647
648    #[test]
649    fn env_load_unset_yields_off() {
650        let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
651        clear_sign_env();
652        let material = SigningKeyMaterial::from_env().expect("unset env -> Off");
653        assert!(material.is_off());
654        assert_eq!(material.kid(), None);
655
656        // Explicit "off" is also accepted.
657        std::env::set_var(ENV_SIGN_ALG, "off");
658        let material2 = SigningKeyMaterial::from_env().expect("explicit off");
659        clear_sign_env();
660        assert!(material2.is_off());
661    }
662
663    // 7. Env mutual-exclusion rejection ------------------------------------
664
665    #[test]
666    fn env_load_rejects_both_hmac_and_ed25519_keys_set() {
667        let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
668        clear_sign_env();
669        std::env::set_var(ENV_SIGN_ALG, "ed25519");
670        std::env::set_var(ENV_SIGN_KID, "kid-conflict");
671        std::env::set_var(ENV_SIGN_HMAC_KEY, URL_SAFE_NO_PAD.encode(b"hmac"));
672        std::env::set_var(ENV_SIGN_ED25519_SK, URL_SAFE_NO_PAD.encode([0u8; 32]));
673
674        let err = SigningKeyMaterial::from_env().expect_err("both keys set must be rejected");
675        clear_sign_env();
676        let msg = format!("{err}");
677        assert!(
678            msg.contains("mutual-exclusion"),
679            "expected mutual-exclusion error, got: {msg}"
680        );
681    }
682
683    // 8. Debug never prints key bytes --------------------------------------
684
685    #[test]
686    fn debug_format_does_not_leak_key_bytes() {
687        let secret_pattern = b"NEVER-EVER-LOG-THIS-SECRET-XYZZY";
688        let hmac = SigningKeyMaterial::Hmac {
689            kid: "kid-redact".into(),
690            key: Zeroizing::new(secret_pattern.to_vec()),
691        };
692        let dbg = format!("{:?}", hmac);
693        assert!(
694            !dbg.contains("NEVER-EVER-LOG-THIS-SECRET"),
695            "Debug for Hmac MUST NOT print key bytes: {dbg}"
696        );
697        assert!(dbg.contains("kid-redact"));
698        assert!(dbg.contains("redacted"));
699
700        // Ed25519 — also redact.
701        let signer = signing_key(99);
702        let ed = SigningKeyMaterial::Ed25519 {
703            kid: "kid-ed-redact".into(),
704            signing_key: signer.clone(),
705        };
706        let dbg_ed = format!("{:?}", ed);
707        // Bytes 0x99 (decimal 153) repeated 32 times — check the seed string
708        // representation never appears in debug.
709        let seed_hex: String = signer
710            .to_bytes()
711            .iter()
712            .map(|b| format!("{:02x}", b))
713            .collect();
714        assert!(
715            !dbg_ed.contains(&seed_hex),
716            "Debug for Ed25519 MUST NOT print key bytes: {dbg_ed}"
717        );
718        assert!(dbg_ed.contains("kid-ed-redact"));
719        assert!(dbg_ed.contains("redacted"));
720
721        // Off variant has nothing to leak — but ensure Debug does not panic.
722        let off = SigningKeyMaterial::Off;
723        let dbg_off = format!("{:?}", off);
724        assert!(dbg_off.contains("Off"));
725    }
726
727    // 9. End-to-end guest -> host-stamp -> sign -> projector-verify --------
728
729    #[test]
730    fn end_to_end_guest_to_projector_verify() {
731        // Producer side: build the full pipeline as the supervisor will.
732        let signer = signing_key(23);
733        let material = SigningKeyMaterial::Ed25519 {
734            kid: "kid-e2e".into(),
735            signing_key: signer.clone(),
736        };
737        let stamped = fixture_decl();
738
739        let outcome = host_stamp_and_sign(
740            &stamped,
741            "ev-e2e-001",
742            "/cellos-supervisor/host-telemetry",
743            "dev.cellos.events.cell.observability.guest.process_spawned",
744            &material,
745        )
746        .expect("e2e sign ok");
747        let signed = match outcome {
748            SigningOutcome::Signed(env) => env,
749            SigningOutcome::Unsigned(_) => panic!("expected Signed"),
750        };
751
752        // Wire the signed envelope through serde to mimic JetStream transit.
753        let wire = serde_json::to_vec(&signed).expect("serialize");
754        let arrived: SignedEventEnvelopeV1 = serde_json::from_slice(&wire).expect("deserialize");
755
756        // Projector side: verify with the public key.
757        let mut verifying: HashMap<String, _> = HashMap::new();
758        verifying.insert("kid-e2e".into(), signer.verifying_key());
759        let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
760        let event = verify_signed_event_envelope(&arrived, &verifying, &hmac_keys)
761            .expect("projector verify");
762
763        // Confirm the host-stamped attribution survived the round trip
764        // intact AND is still the source of truth (overrode any guest-claimed
765        // values per ADR-0006 §6).
766        assert_eq!(event.id, "ev-e2e-001");
767        assert_eq!(
768            event.ty,
769            "dev.cellos.events.cell.observability.guest.process_spawned"
770        );
771        let data = event.data.as_ref().expect("data");
772        assert_eq!(data["provenance"], "declared");
773        assert_eq!(data["host"]["cellId"], "cell-abc");
774        assert_eq!(data["host"]["runId"], "run-xyz");
775        assert_eq!(data["host"]["specSignatureHash"], "sha256:deadbeef");
776        assert_eq!(data["guest"]["probeSource"], "process_spawned");
777    }
778
779    // ── Bonus coverage for the self-contained RFC3339 formatter ──────────
780
781    #[test]
782    fn rfc3339_formatter_matches_known_anchors() {
783        // Epoch zero — definitionally fixed.
784        assert_eq!(format_unix_secs_rfc3339(0), "1970-01-01T00:00:00Z");
785
786        // One day later — exercises the day-rollover path.
787        assert_eq!(format_unix_secs_rfc3339(86_400), "1970-01-02T00:00:00Z");
788
789        // Time-of-day component: 1h1m1s past epoch.
790        assert_eq!(format_unix_secs_rfc3339(3_661), "1970-01-01T01:01:01Z");
791
792        // 2000-01-01T00:00:00Z is a well-known anchor (946684800 = 30 years
793        // of seconds with 7 leap days: 30*365*86400 + 7*86400 = 946684800).
794        assert_eq!(
795            format_unix_secs_rfc3339(946_684_800),
796            "2000-01-01T00:00:00Z"
797        );
798
799        // Leap-year edge: 2020-01-01 anchor = 50*365*86400 + 12*86400 leap
800        // days (1972/76/80/84/88/92/96/2000/04/08/12/16) = 1577836800. Feb 29
801        // is day index 59 from Jan 1 → +59*86400 + 12*3600 + 34*60 + 56
802        // = 1582934400 + 45296 = 1582979696.
803        assert_eq!(
804            format_unix_secs_rfc3339(1_582_979_696),
805            "2020-02-29T12:34:56Z"
806        );
807    }
808}