use std::env;
use std::time::UNIX_EPOCH;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::SigningKey;
use zeroize::Zeroizing;
use cellos_core::trust_keys::{sign_event_ed25519, sign_event_hmac_sha256, SignedEventEnvelopeV1};
use cellos_core::CloudEventV1;
use crate::{GuestDeclaration, HostStamp};
#[derive(Debug, Clone)]
pub struct StampedDeclaration {
pub guest: GuestDeclaration,
pub host: HostStamp,
}
pub const PROVENANCE_DECLARED: &str = "declared";
pub const ENV_SIGN_ALG: &str = "CELLOS_HOST_TELEMETRY_SIGN_ALG";
pub const ENV_SIGN_KID: &str = "CELLOS_HOST_TELEMETRY_SIGN_KID";
pub const ENV_SIGN_HMAC_KEY: &str = "CELLOS_HOST_TELEMETRY_SIGN_HMAC_KEY";
pub const ENV_SIGN_ED25519_SK: &str = "CELLOS_HOST_TELEMETRY_SIGN_ED25519_SK";
#[derive(Debug, thiserror::Error)]
pub enum SignOutboundError {
#[error("invalid signing config: {0}")]
InvalidConfig(String),
#[error("signer error: {0}")]
Signer(String),
#[error("serialize error: {0}")]
Serialize(String),
}
pub enum SigningKeyMaterial {
Off,
Hmac {
kid: String,
key: Zeroizing<Vec<u8>>,
},
Ed25519 {
kid: String,
signing_key: SigningKey,
},
}
impl std::fmt::Debug for SigningKeyMaterial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SigningKeyMaterial::Off => f.debug_struct("SigningKeyMaterial::Off").finish(),
SigningKeyMaterial::Hmac { kid, key } => f
.debug_struct("SigningKeyMaterial::Hmac")
.field("kid", kid)
.field(
"key",
&format_args!("<redacted {}-byte hmac key>", key.len()),
)
.finish(),
SigningKeyMaterial::Ed25519 { kid, .. } => f
.debug_struct("SigningKeyMaterial::Ed25519")
.field("kid", kid)
.field("signing_key", &"<redacted ed25519 signing key>")
.finish(),
}
}
}
impl SigningKeyMaterial {
pub fn is_off(&self) -> bool {
matches!(self, SigningKeyMaterial::Off)
}
pub fn kid(&self) -> Option<&str> {
match self {
SigningKeyMaterial::Off => None,
SigningKeyMaterial::Hmac { kid, .. } => Some(kid.as_str()),
SigningKeyMaterial::Ed25519 { kid, .. } => Some(kid.as_str()),
}
}
pub fn from_env() -> Result<Self, SignOutboundError> {
let alg_raw = env::var(ENV_SIGN_ALG).unwrap_or_default();
let alg = alg_raw.trim().to_ascii_lowercase();
if alg.is_empty() || alg == "off" {
if env::var(ENV_SIGN_HMAC_KEY).is_ok() || env::var(ENV_SIGN_ED25519_SK).is_ok() {
return Err(SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_ALG} is off (or unset) but {ENV_SIGN_HMAC_KEY} \
or {ENV_SIGN_ED25519_SK} is set — refuse to silently \
drop key material; set {ENV_SIGN_ALG} explicitly"
)));
}
return Ok(SigningKeyMaterial::Off);
}
let kid = env::var(ENV_SIGN_KID).map_err(|_| {
SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_ALG}={alg_raw:?} requires {ENV_SIGN_KID} to be set"
))
})?;
if kid.trim().is_empty() {
return Err(SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_KID} must be a non-empty signer kid"
)));
}
let hmac_set = env::var(ENV_SIGN_HMAC_KEY).is_ok();
let ed_set = env::var(ENV_SIGN_ED25519_SK).is_ok();
if hmac_set && ed_set {
return Err(SignOutboundError::InvalidConfig(format!(
"mutual-exclusion violated: both {ENV_SIGN_HMAC_KEY} and \
{ENV_SIGN_ED25519_SK} are set — pick exactly one"
)));
}
match alg.as_str() {
"hmac-sha256" | "hmac" => {
let key_b64 = env::var(ENV_SIGN_HMAC_KEY).map_err(|_| {
SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_ALG}=hmac-sha256 requires {ENV_SIGN_HMAC_KEY}"
))
})?;
let trimmed = key_b64.trim().trim_end_matches('=');
let key: Zeroizing<Vec<u8>> =
Zeroizing::new(URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_HMAC_KEY} is not valid base64url: {e}"
))
})?);
if key.is_empty() {
return Err(SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_HMAC_KEY} decoded to zero bytes"
)));
}
Ok(SigningKeyMaterial::Hmac { kid, key })
}
"ed25519" => {
let sk_b64 = env::var(ENV_SIGN_ED25519_SK).map_err(|_| {
SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_ALG}=ed25519 requires {ENV_SIGN_ED25519_SK}"
))
})?;
let trimmed = sk_b64.trim().trim_end_matches('=');
let bytes = URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_ED25519_SK} is not valid base64url: {e}"
))
})?;
let seed: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
SignOutboundError::InvalidConfig(format!(
"{ENV_SIGN_ED25519_SK} decoded to {} bytes, expected 32",
bytes.len()
))
})?;
let signing_key = SigningKey::from_bytes(&seed);
Ok(SigningKeyMaterial::Ed25519 { kid, signing_key })
}
other => Err(SignOutboundError::InvalidConfig(format!(
"unknown {ENV_SIGN_ALG}={other:?} (expected off, hmac-sha256, or ed25519)"
))),
}
}
}
#[derive(Debug, Clone)]
pub enum SigningOutcome {
Unsigned(CloudEventV1),
Signed(SignedEventEnvelopeV1),
}
impl SigningOutcome {
pub fn event(&self) -> &CloudEventV1 {
match self {
SigningOutcome::Unsigned(ev) => ev,
SigningOutcome::Signed(env) => &env.event,
}
}
}
pub fn host_stamped_envelope(
stamped: &StampedDeclaration,
event_id: &str,
source: &str,
event_type: &str,
) -> Result<CloudEventV1, SignOutboundError> {
let host_received_unix_secs = stamped
.host
.host_received_at
.duration_since(UNIX_EPOCH)
.map_err(|e| {
SignOutboundError::Serialize(format!("host_received_at predates UNIX_EPOCH: {e}"))
})?
.as_secs();
let host_received_at_rfc3339 = format_unix_secs_rfc3339(host_received_unix_secs);
let data = serde_json::json!({
"provenance": PROVENANCE_DECLARED,
"guest": {
"probeSource": stamped.guest.probe_source,
"guestPid": stamped.guest.guest_pid,
"guestComm": stamped.guest.guest_comm,
"guestMonotonicNs": stamped.guest.guest_monotonic_ns,
},
"host": {
"cellId": stamped.host.cell_id,
"runId": stamped.host.run_id,
"hostReceivedAt": host_received_at_rfc3339,
"specSignatureHash": stamped.host.spec_signature_hash,
},
});
Ok(CloudEventV1 {
specversion: "1.0".into(),
id: event_id.to_string(),
source: source.to_string(),
ty: event_type.to_string(),
datacontenttype: Some("application/json".into()),
data: Some(data),
time: Some(host_received_at_rfc3339),
traceparent: None,
})
}
pub fn sign_host_stamped_envelope(
envelope: CloudEventV1,
material: &SigningKeyMaterial,
) -> Result<SigningOutcome, SignOutboundError> {
match material {
SigningKeyMaterial::Off => Ok(SigningOutcome::Unsigned(envelope)),
SigningKeyMaterial::Hmac { kid, key } => {
let signed = sign_event_hmac_sha256(&envelope, kid, key)
.map_err(|e| SignOutboundError::Signer(format!("{e}")))?;
Ok(SigningOutcome::Signed(signed))
}
SigningKeyMaterial::Ed25519 { kid, signing_key } => {
let signed = sign_event_ed25519(&envelope, kid, signing_key)
.map_err(|e| SignOutboundError::Signer(format!("{e}")))?;
Ok(SigningOutcome::Signed(signed))
}
}
}
pub fn host_stamp_and_sign(
stamped: &StampedDeclaration,
event_id: &str,
source: &str,
event_type: &str,
material: &SigningKeyMaterial,
) -> Result<SigningOutcome, SignOutboundError> {
let envelope = host_stamped_envelope(stamped, event_id, source, event_type)?;
sign_host_stamped_envelope(envelope, material)
}
fn format_unix_secs_rfc3339(unix_secs: u64) -> String {
let secs_per_day: u64 = 86_400;
let days = (unix_secs / secs_per_day) as i64; let secs_of_day = unix_secs % secs_per_day;
let hour = (secs_of_day / 3600) as u32;
let minute = ((secs_of_day % 3600) / 60) as u32;
let second = (secs_of_day % 60) as u32;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let year = if m <= 2 { y + 1 } else { y };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, m, d, hour, minute, second
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use cellos_core::trust_keys::verify_signed_event_envelope;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
fn fixture_decl() -> StampedDeclaration {
StampedDeclaration {
guest: GuestDeclaration {
probe_source: "process_spawned".into(),
guest_pid: 4242,
guest_comm: "curl".into(),
guest_monotonic_ns: 1_234_567_890,
},
host: HostStamp {
cell_id: "cell-abc".into(),
run_id: "run-xyz".into(),
host_received_at: UNIX_EPOCH + Duration::from_secs(1_762_348_800),
spec_signature_hash: "sha256:deadbeef".into(),
},
}
}
fn signing_key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn clear_sign_env() {
std::env::remove_var(ENV_SIGN_ALG);
std::env::remove_var(ENV_SIGN_KID);
std::env::remove_var(ENV_SIGN_HMAC_KEY);
std::env::remove_var(ENV_SIGN_ED25519_SK);
}
#[test]
fn hmac_round_trip_verifies_via_projector_path() {
let key = b"f4b-shared-symmetric-key-for-tests";
let material = SigningKeyMaterial::Hmac {
kid: "ops-host-telem-2026-q2".into(),
key: Zeroizing::new(key.to_vec()),
};
let outcome = host_stamp_and_sign(
&fixture_decl(),
"ev-hmac-001",
"/cellos-supervisor/host-telemetry",
"dev.cellos.events.cell.observability.guest.process_spawned",
&material,
)
.expect("sign ok");
let envelope = match outcome {
SigningOutcome::Signed(env) => env,
SigningOutcome::Unsigned(_) => panic!("expected Signed"),
};
assert_eq!(envelope.algorithm, "hmac-sha256");
assert_eq!(envelope.signer_kid, "ops-host-telem-2026-q2");
let verifying_keys: HashMap<String, _> = HashMap::new();
let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
hmac_keys.insert("ops-host-telem-2026-q2".into(), key.to_vec());
let verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
.expect("hmac round-trip must verify");
assert_eq!(verified.id, "ev-hmac-001");
}
#[test]
fn ed25519_round_trip_verifies_via_projector_path() {
let signer = signing_key(13);
let material = SigningKeyMaterial::Ed25519 {
kid: "ops-host-telem-ed-2026-q2".into(),
signing_key: signer.clone(),
};
let outcome = host_stamp_and_sign(
&fixture_decl(),
"ev-ed25519-001",
"/cellos-supervisor/host-telemetry",
"dev.cellos.events.cell.observability.guest.process_spawned",
&material,
)
.expect("sign ok");
let envelope = match outcome {
SigningOutcome::Signed(env) => env,
SigningOutcome::Unsigned(_) => panic!("expected Signed"),
};
assert_eq!(envelope.algorithm, "ed25519");
let mut verifying_keys: HashMap<String, _> = HashMap::new();
verifying_keys.insert("ops-host-telem-ed-2026-q2".into(), signer.verifying_key());
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
.expect("ed25519 round-trip must verify");
assert_eq!(verified.id, "ev-ed25519-001");
}
#[test]
fn mutating_event_after_sign_breaks_verification() {
let signer = signing_key(17);
let material = SigningKeyMaterial::Ed25519 {
kid: "kid-mut".into(),
signing_key: signer.clone(),
};
let outcome = host_stamp_and_sign(
&fixture_decl(),
"ev-mut-001",
"/cellos-supervisor/host-telemetry",
"dev.cellos.events.cell.observability.guest.process_spawned",
&material,
)
.expect("sign ok");
let mut envelope = match outcome {
SigningOutcome::Signed(env) => env,
SigningOutcome::Unsigned(_) => panic!("expected Signed"),
};
if let Some(serde_json::Value::Object(map)) = envelope.event.data.as_mut() {
if let Some(serde_json::Value::Object(host)) = map.get_mut("host") {
host.insert(
"cellId".into(),
serde_json::Value::String("cell-EVIL".into()),
);
}
}
let mut verifying_keys: HashMap<String, _> = HashMap::new();
verifying_keys.insert("kid-mut".into(), signer.verifying_key());
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
.expect_err("post-sign mutation must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("ed25519 verify failed"),
"expected ed25519 verify failure, got: {msg}"
);
}
#[test]
fn off_mode_emits_unsigned_passthrough() {
let outcome = host_stamp_and_sign(
&fixture_decl(),
"ev-off-001",
"/cellos-supervisor/host-telemetry",
"dev.cellos.events.cell.observability.guest.process_spawned",
&SigningKeyMaterial::Off,
)
.expect("off-mode stamp ok");
match outcome {
SigningOutcome::Unsigned(ev) => {
assert_eq!(ev.id, "ev-off-001");
let data = ev.data.expect("data present");
assert_eq!(data["provenance"], "declared");
assert_eq!(data["host"]["cellId"], "cell-abc");
}
SigningOutcome::Signed(_) => panic!("Off must passthrough"),
}
}
#[test]
fn env_load_hmac_round_trips_a_signed_envelope() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
clear_sign_env();
let key = b"env-hmac-key-for-from_env-test";
std::env::set_var(ENV_SIGN_ALG, "hmac-sha256");
std::env::set_var(ENV_SIGN_KID, "kid-from-env");
std::env::set_var(ENV_SIGN_HMAC_KEY, URL_SAFE_NO_PAD.encode(key));
let material = SigningKeyMaterial::from_env().expect("from_env hmac ok");
clear_sign_env();
assert!(!material.is_off());
assert_eq!(material.kid(), Some("kid-from-env"));
let outcome = host_stamp_and_sign(
&fixture_decl(),
"ev-env-hmac",
"/cellos-supervisor/host-telemetry",
"dev.cellos.events.cell.observability.guest.process_spawned",
&material,
)
.expect("sign ok");
let envelope = match outcome {
SigningOutcome::Signed(env) => env,
SigningOutcome::Unsigned(_) => panic!("expected Signed"),
};
assert_eq!(envelope.algorithm, "hmac-sha256");
let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
hmac_keys.insert("kid-from-env".into(), key.to_vec());
let verifying: HashMap<String, _> = HashMap::new();
verify_signed_event_envelope(&envelope, &verifying, &hmac_keys)
.expect("env-loaded hmac must verify");
}
#[test]
fn env_load_unset_yields_off() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
clear_sign_env();
let material = SigningKeyMaterial::from_env().expect("unset env -> Off");
assert!(material.is_off());
assert_eq!(material.kid(), None);
std::env::set_var(ENV_SIGN_ALG, "off");
let material2 = SigningKeyMaterial::from_env().expect("explicit off");
clear_sign_env();
assert!(material2.is_off());
}
#[test]
fn env_load_rejects_both_hmac_and_ed25519_keys_set() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|p| p.into_inner());
clear_sign_env();
std::env::set_var(ENV_SIGN_ALG, "ed25519");
std::env::set_var(ENV_SIGN_KID, "kid-conflict");
std::env::set_var(ENV_SIGN_HMAC_KEY, URL_SAFE_NO_PAD.encode(b"hmac"));
std::env::set_var(ENV_SIGN_ED25519_SK, URL_SAFE_NO_PAD.encode([0u8; 32]));
let err = SigningKeyMaterial::from_env().expect_err("both keys set must be rejected");
clear_sign_env();
let msg = format!("{err}");
assert!(
msg.contains("mutual-exclusion"),
"expected mutual-exclusion error, got: {msg}"
);
}
#[test]
fn debug_format_does_not_leak_key_bytes() {
let secret_pattern = b"NEVER-EVER-LOG-THIS-SECRET-XYZZY";
let hmac = SigningKeyMaterial::Hmac {
kid: "kid-redact".into(),
key: Zeroizing::new(secret_pattern.to_vec()),
};
let dbg = format!("{:?}", hmac);
assert!(
!dbg.contains("NEVER-EVER-LOG-THIS-SECRET"),
"Debug for Hmac MUST NOT print key bytes: {dbg}"
);
assert!(dbg.contains("kid-redact"));
assert!(dbg.contains("redacted"));
let signer = signing_key(99);
let ed = SigningKeyMaterial::Ed25519 {
kid: "kid-ed-redact".into(),
signing_key: signer.clone(),
};
let dbg_ed = format!("{:?}", ed);
let seed_hex: String = signer
.to_bytes()
.iter()
.map(|b| format!("{:02x}", b))
.collect();
assert!(
!dbg_ed.contains(&seed_hex),
"Debug for Ed25519 MUST NOT print key bytes: {dbg_ed}"
);
assert!(dbg_ed.contains("kid-ed-redact"));
assert!(dbg_ed.contains("redacted"));
let off = SigningKeyMaterial::Off;
let dbg_off = format!("{:?}", off);
assert!(dbg_off.contains("Off"));
}
#[test]
fn end_to_end_guest_to_projector_verify() {
let signer = signing_key(23);
let material = SigningKeyMaterial::Ed25519 {
kid: "kid-e2e".into(),
signing_key: signer.clone(),
};
let stamped = fixture_decl();
let outcome = host_stamp_and_sign(
&stamped,
"ev-e2e-001",
"/cellos-supervisor/host-telemetry",
"dev.cellos.events.cell.observability.guest.process_spawned",
&material,
)
.expect("e2e sign ok");
let signed = match outcome {
SigningOutcome::Signed(env) => env,
SigningOutcome::Unsigned(_) => panic!("expected Signed"),
};
let wire = serde_json::to_vec(&signed).expect("serialize");
let arrived: SignedEventEnvelopeV1 = serde_json::from_slice(&wire).expect("deserialize");
let mut verifying: HashMap<String, _> = HashMap::new();
verifying.insert("kid-e2e".into(), signer.verifying_key());
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let event = verify_signed_event_envelope(&arrived, &verifying, &hmac_keys)
.expect("projector verify");
assert_eq!(event.id, "ev-e2e-001");
assert_eq!(
event.ty,
"dev.cellos.events.cell.observability.guest.process_spawned"
);
let data = event.data.as_ref().expect("data");
assert_eq!(data["provenance"], "declared");
assert_eq!(data["host"]["cellId"], "cell-abc");
assert_eq!(data["host"]["runId"], "run-xyz");
assert_eq!(data["host"]["specSignatureHash"], "sha256:deadbeef");
assert_eq!(data["guest"]["probeSource"], "process_spawned");
}
#[test]
fn rfc3339_formatter_matches_known_anchors() {
assert_eq!(format_unix_secs_rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(format_unix_secs_rfc3339(86_400), "1970-01-02T00:00:00Z");
assert_eq!(format_unix_secs_rfc3339(3_661), "1970-01-01T01:01:01Z");
assert_eq!(
format_unix_secs_rfc3339(946_684_800),
"2000-01-01T00:00:00Z"
);
assert_eq!(
format_unix_secs_rfc3339(1_582_979_696),
"2020-02-29T12:34:56Z"
);
}
}