use std::sync::Arc;
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use cellos_core::ports::EventSink;
use cellos_core::{
sign_event_hmac_sha256, sign_event_with, CellosError, CloudEventV1, SignedEventEnvelopeV1,
Signer, SoftwareSigner,
};
use cellos_sink_redact::{redact_fields_from_env, RedactingEventSink};
use std::collections::HashSet;
pub const SIGNED_ENVELOPE_TRANSPORT_TYPE: &str = "dev.cellos.events.signed_envelope.v1";
#[derive(Debug, Clone)]
pub struct EventSigningConfigWarning {
pub var: &'static str,
pub value: String,
pub reason: String,
}
const WRAPPER_SOURCE: &str = "/cellos-supervisor/event-signing";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Algorithm {
Ed25519,
HmacSha256,
}
#[derive(Clone, zeroize::ZeroizeOnDrop)]
struct SigningConfig {
#[zeroize(skip)]
algorithm: Algorithm,
#[zeroize(skip)]
kid: String,
key_bytes: zeroize::Zeroizing<Vec<u8>>,
}
fn parse_signing_config_from_env() -> (Option<SigningConfig>, Vec<EventSigningConfigWarning>) {
let mut warnings: Vec<EventSigningConfigWarning> = Vec::new();
let toggle = match std::env::var("CELLOS_EVENT_SIGNING") {
Ok(v) => v,
Err(_) => return (None, warnings),
};
let toggle_norm = toggle.trim().to_ascii_lowercase();
if toggle_norm.is_empty() || toggle_norm == "off" {
return (None, warnings);
}
let algorithm = match toggle_norm.as_str() {
"ed25519" => Algorithm::Ed25519,
"hmac" | "hmac-sha256" => Algorithm::HmacSha256,
other => {
tracing::warn!(
target: "cellos.supervisor.event_signing",
toggle = %other,
"CELLOS_EVENT_SIGNING: unknown algorithm — signing disabled (expected off|ed25519|hmac)"
);
warnings.push(EventSigningConfigWarning {
var: "CELLOS_EVENT_SIGNING",
value: other.to_string(),
reason: "unknown algorithm (expected off|ed25519|hmac); signing disabled".into(),
});
return (None, warnings);
}
};
let kid = match std::env::var("CELLOS_EVENT_SIGNING_KID") {
Ok(k) => k.trim().to_string(),
Err(_) => {
tracing::warn!(
target: "cellos.supervisor.event_signing",
"CELLOS_EVENT_SIGNING is set but CELLOS_EVENT_SIGNING_KID is missing — signing disabled"
);
warnings.push(EventSigningConfigWarning {
var: "CELLOS_EVENT_SIGNING_KID",
value: String::new(),
reason: format!(
"CELLOS_EVENT_SIGNING={toggle_norm} requires a kid but CELLOS_EVENT_SIGNING_KID is unset; signing disabled"
),
});
return (None, warnings);
}
};
if kid.is_empty() {
tracing::warn!(
target: "cellos.supervisor.event_signing",
"CELLOS_EVENT_SIGNING_KID is empty — signing disabled"
);
warnings.push(EventSigningConfigWarning {
var: "CELLOS_EVENT_SIGNING_KID",
value: String::new(),
reason: "kid is empty; signing disabled".into(),
});
return (None, warnings);
}
let key_b64 = match std::env::var("CELLOS_EVENT_SIGNING_KEY_BASE64") {
Ok(k) => k,
Err(_) => {
tracing::warn!(
target: "cellos.supervisor.event_signing",
"CELLOS_EVENT_SIGNING is set but CELLOS_EVENT_SIGNING_KEY_BASE64 is missing — signing disabled"
);
warnings.push(EventSigningConfigWarning {
var: "CELLOS_EVENT_SIGNING_KEY_BASE64",
value: String::new(),
reason: format!(
"CELLOS_EVENT_SIGNING={toggle_norm} requires a key but CELLOS_EVENT_SIGNING_KEY_BASE64 is unset; signing disabled"
),
});
return (None, warnings);
}
};
let trimmed = key_b64.trim().trim_end_matches('=');
let key_bytes: zeroize::Zeroizing<Vec<u8>> = match URL_SAFE_NO_PAD.decode(trimmed) {
Ok(b) => zeroize::Zeroizing::new(b),
Err(e) => {
tracing::warn!(
target: "cellos.supervisor.event_signing",
error = %e,
"CELLOS_EVENT_SIGNING_KEY_BASE64: invalid base64url — signing disabled"
);
warnings.push(EventSigningConfigWarning {
var: "CELLOS_EVENT_SIGNING_KEY_BASE64",
value: "<base64 decode failed>".into(),
reason: format!("invalid base64url: {e}; signing disabled"),
});
return (None, warnings);
}
};
match algorithm {
Algorithm::Ed25519 if key_bytes.len() != 32 => {
tracing::warn!(
target: "cellos.supervisor.event_signing",
got_bytes = key_bytes.len(),
"CELLOS_EVENT_SIGNING=ed25519 requires a 32-byte key — signing disabled"
);
warnings.push(EventSigningConfigWarning {
var: "CELLOS_EVENT_SIGNING_KEY_BASE64",
value: format!("<{} bytes>", key_bytes.len()),
reason: format!(
"CELLOS_EVENT_SIGNING=ed25519 requires a 32-byte key, got {}; signing disabled",
key_bytes.len()
),
});
return (None, warnings);
}
Algorithm::HmacSha256 if key_bytes.is_empty() => {
tracing::warn!(
target: "cellos.supervisor.event_signing",
"CELLOS_EVENT_SIGNING=hmac requires a non-empty key — signing disabled"
);
warnings.push(EventSigningConfigWarning {
var: "CELLOS_EVENT_SIGNING_KEY_BASE64",
value: "<empty>".into(),
reason: "CELLOS_EVENT_SIGNING=hmac requires a non-empty key; signing disabled"
.into(),
});
return (None, warnings);
}
_ => {}
}
let algo_label = match algorithm {
Algorithm::Ed25519 => "ed25519",
Algorithm::HmacSha256 => "hmac-sha256",
};
tracing::info!(
target: "cellos.supervisor.event_signing",
algorithm = %algo_label,
kid = %kid,
"per-event signing enabled"
);
(
Some(SigningConfig {
algorithm,
kid,
key_bytes,
}),
warnings,
)
}
pub fn signing_enabled_from_env() -> bool {
parse_signing_config_from_env().0.is_some()
}
pub(crate) fn ed25519_signer_from_env() -> Option<SoftwareSigner> {
let (cfg, _warnings) = parse_signing_config_from_env();
let cfg = cfg?;
match cfg.algorithm {
Algorithm::Ed25519 => {
let seed: [u8; 32] = cfg.key_bytes.as_slice().try_into().ok()?;
SoftwareSigner::from_seed(&cfg.kid, seed).ok()
}
Algorithm::HmacSha256 => None,
}
}
pub struct SigningEventSink {
inner: Arc<dyn EventSink>,
cfg: SigningConfig,
}
impl SigningEventSink {
fn new(inner: Arc<dyn EventSink>, cfg: SigningConfig) -> Self {
Self { inner, cfg }
}
pub fn from_env(inner: Arc<dyn EventSink>) -> Arc<dyn EventSink> {
let (sink, _warnings) = Self::from_env_with_warnings(inner);
sink
}
pub fn from_env_with_warnings(
inner: Arc<dyn EventSink>,
) -> (Arc<dyn EventSink>, Vec<EventSigningConfigWarning>) {
let (cfg, warnings) = parse_signing_config_from_env();
match cfg {
Some(cfg) => (Arc::new(Self::new(inner, cfg)), warnings),
None => (inner, warnings),
}
}
fn wrap(&self, event: &CloudEventV1) -> Result<CloudEventV1, CellosError> {
let envelope = sign_event_with_config(event, &self.cfg)?;
let data = serde_json::to_value(&envelope).map_err(|e| {
CellosError::InvalidSpec(format!("event signing: serialize envelope: {e}"))
})?;
Ok(wrap_signed_envelope(event, data))
}
}
fn sign_event_with_config(
event: &CloudEventV1,
cfg: &SigningConfig,
) -> Result<SignedEventEnvelopeV1, CellosError> {
match cfg.algorithm {
Algorithm::Ed25519 => {
let seed: [u8; 32] = cfg.key_bytes.as_slice().try_into().map_err(|_| {
CellosError::InvalidSpec(format!(
"event signing: ed25519 key must be 32 bytes, got {}",
cfg.key_bytes.len()
))
})?;
let signer: Arc<dyn Signer> = Arc::new(SoftwareSigner::from_seed(&cfg.kid, seed)?);
sign_event_with(signer.as_ref(), event)
}
Algorithm::HmacSha256 => sign_event_hmac_sha256(event, &cfg.kid, &cfg.key_bytes),
}
}
pub(crate) fn wrap_signed_envelope(
event: &CloudEventV1,
envelope_data: serde_json::Value,
) -> CloudEventV1 {
CloudEventV1 {
specversion: "1.0".into(),
id: event.id.clone(),
source: WRAPPER_SOURCE.to_string(),
ty: SIGNED_ENVELOPE_TRANSPORT_TYPE.to_string(),
datacontenttype: Some("application/json".into()),
data: Some(envelope_data),
time: event.time.clone(),
traceparent: event.traceparent.clone(),
cex: None,
}
}
#[async_trait]
impl EventSink for SigningEventSink {
async fn emit(&self, event: &CloudEventV1) -> Result<(), CellosError> {
let wrapped = self.wrap(event)?;
self.inner.emit(&wrapped).await
}
async fn flush(&self) -> Result<(), CellosError> {
self.inner.flush().await
}
}
const SIGNED_MARKER_FIELD: &str = "signed";
pub struct StandaloneEventSigner {
redacting: Arc<dyn EventSink>,
transport: Arc<dyn EventSink>,
cfg: Option<SigningConfig>,
redact_fields: Option<HashSet<String>>,
}
impl StandaloneEventSigner {
pub fn from_env(transport: Arc<dyn EventSink>) -> (Self, Vec<EventSigningConfigWarning>) {
let (cfg, warnings) = parse_signing_config_from_env();
let redact_fields = redact_fields_from_env();
let redacting =
RedactingEventSink::with_resolved_fields(transport.clone(), redact_fields.as_ref());
(
Self {
redacting,
transport,
cfg,
redact_fields,
},
warnings,
)
}
pub fn is_signing_enabled(&self) -> bool {
self.cfg.is_some()
}
pub async fn flush(&self) -> Result<(), CellosError> {
self.transport.flush().await
}
async fn redact(&self, event: &CloudEventV1) -> Result<CloudEventV1, CellosError> {
let capture = CaptureOnceSink::new();
self.redacting_for(capture.clone()).emit(event).await?;
capture.take().ok_or_else(|| {
CellosError::InvalidSpec(
"standalone signer: redaction sink dropped the event".to_string(),
)
})
}
fn redacting_for(&self, inner: Arc<dyn EventSink>) -> Arc<dyn EventSink> {
RedactingEventSink::with_resolved_fields(inner, self.redact_fields.as_ref())
}
pub async fn emit_decision(
&self,
event: &CloudEventV1,
) -> Result<Option<SignedEventEnvelopeV1>, CellosError> {
let redacted = self.redact(event).await?;
match &self.cfg {
Some(cfg) => {
let envelope = sign_event_with_config(&redacted, cfg)?;
let data = serde_json::to_value(&envelope).map_err(|e| {
CellosError::InvalidSpec(format!("standalone signer: serialize envelope: {e}"))
})?;
let wrapper = wrap_signed_envelope(&redacted, data);
self.transport.emit(&wrapper).await?;
Ok(Some(envelope))
}
None => {
let mut advisory = redacted;
let data = advisory
.data
.get_or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let serde_json::Value::Object(map) = data {
map.insert(
SIGNED_MARKER_FIELD.to_string(),
serde_json::Value::Bool(false),
);
}
self.redacting.emit(&advisory).await?;
Ok(None)
}
}
}
}
struct CaptureOnceSink(std::sync::Mutex<Option<CloudEventV1>>);
impl CaptureOnceSink {
fn new() -> Arc<Self> {
Arc::new(Self(std::sync::Mutex::new(None)))
}
fn take(&self) -> Option<CloudEventV1> {
self.0.lock().expect("capture-once lock poisoned").take()
}
}
#[async_trait]
impl EventSink for CaptureOnceSink {
async fn emit(&self, event: &CloudEventV1) -> Result<(), CellosError> {
*self.0.lock().expect("capture-once lock poisoned") = Some(event.clone());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use cellos_core::{verify_signed_event_envelope, CloudEventV1};
use ed25519_dalek::SigningKey;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Mutex;
struct CaptureSink(Mutex<Option<CloudEventV1>>);
impl CaptureSink {
fn new() -> Arc<Self> {
Arc::new(Self(Mutex::new(None)))
}
fn last(&self) -> Option<CloudEventV1> {
self.0.lock().unwrap().clone()
}
}
#[async_trait]
impl EventSink for CaptureSink {
async fn emit(&self, event: &CloudEventV1) -> Result<(), CellosError> {
*self.0.lock().unwrap() = Some(event.clone());
Ok(())
}
}
fn sample_event() -> CloudEventV1 {
CloudEventV1 {
specversion: "1.0".into(),
id: "ev-001".into(),
source: "/cellos-supervisor".into(),
ty: "dev.cellos.events.cell.lifecycle.v1.started".into(),
datacontenttype: Some("application/json".into()),
data: Some(json!({"cellId": "test-cell-1"})),
time: Some("2026-05-06T12:00:00Z".into()),
traceparent: None,
cex: None,
}
}
static FROM_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn clear_signing_env() {
std::env::remove_var("CELLOS_EVENT_SIGNING");
std::env::remove_var("CELLOS_EVENT_SIGNING_KID");
std::env::remove_var("CELLOS_EVENT_SIGNING_KEY_BASE64");
}
#[tokio::test]
async fn from_env_off_passes_through_unwrapped() {
let capture = CaptureSink::new();
let sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
std::env::set_var("CELLOS_EVENT_SIGNING", "off");
SigningEventSink::from_env(capture.clone() as Arc<dyn EventSink>)
};
let event = sample_event();
sink.emit(&event).await.unwrap();
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
}
let got = capture.last().unwrap();
assert_eq!(
got.ty, "dev.cellos.events.cell.lifecycle.v1.started",
"off must pass through the original event unchanged"
);
}
#[tokio::test]
async fn from_env_unknown_toggle_disables_signing() {
let capture = CaptureSink::new();
let sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
std::env::set_var("CELLOS_EVENT_SIGNING", "rsa-pss-sha512");
std::env::set_var("CELLOS_EVENT_SIGNING_KID", "ops-event-2026-q2");
std::env::set_var(
"CELLOS_EVENT_SIGNING_KEY_BASE64",
URL_SAFE_NO_PAD.encode([7u8; 32]),
);
SigningEventSink::from_env(capture.clone() as Arc<dyn EventSink>)
};
let event = sample_event();
sink.emit(&event).await.unwrap();
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
}
let got = capture.last().unwrap();
assert_eq!(
got.ty, "dev.cellos.events.cell.lifecycle.v1.started",
"unknown algorithm must fall back to passthrough"
);
}
#[tokio::test]
async fn from_env_ed25519_round_trip_via_sink() {
let capture = CaptureSink::new();
let signer_seed = [13u8; 32];
let signer = SigningKey::from_bytes(&signer_seed);
let sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
std::env::set_var("CELLOS_EVENT_SIGNING", "ed25519");
std::env::set_var("CELLOS_EVENT_SIGNING_KID", "ops-event-2026-q2");
std::env::set_var(
"CELLOS_EVENT_SIGNING_KEY_BASE64",
URL_SAFE_NO_PAD.encode(signer_seed),
);
SigningEventSink::from_env(capture.clone() as Arc<dyn EventSink>)
};
let event = sample_event();
sink.emit(&event).await.unwrap();
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
}
let got = capture.last().expect("wrapper emitted");
assert_eq!(got.ty, SIGNED_ENVELOPE_TRANSPORT_TYPE);
let envelope: SignedEventEnvelopeV1 =
serde_json::from_value(got.data.expect("wrapper has data")).expect("parse envelope");
assert_eq!(envelope.algorithm, "ed25519");
assert_eq!(envelope.signer_kid, "ops-event-2026-q2");
let mut keys = HashMap::new();
keys.insert(
"ops-event-2026-q2".to_string(),
cellos_core::TrustAnchorPublicKey::from_bytes_unchecked(
signer.verifying_key().to_bytes(),
),
);
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let inner = verify_signed_event_envelope(&envelope, &keys, &hmac_keys).expect("verify ok");
assert_eq!(inner.id, event.id, "inner event id round-trips");
assert_eq!(inner.ty, event.ty);
}
#[tokio::test]
async fn from_env_hmac_round_trip_via_sink() {
let capture = CaptureSink::new();
let key = b"super-secret-shared-symmetric-key-bytes-padded";
let sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
std::env::set_var("CELLOS_EVENT_SIGNING", "hmac");
std::env::set_var("CELLOS_EVENT_SIGNING_KID", "ops-hmac-2026-q2");
std::env::set_var(
"CELLOS_EVENT_SIGNING_KEY_BASE64",
URL_SAFE_NO_PAD.encode(key),
);
SigningEventSink::from_env(capture.clone() as Arc<dyn EventSink>)
};
let event = sample_event();
sink.emit(&event).await.unwrap();
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
}
let got = capture.last().expect("wrapper emitted");
assert_eq!(got.ty, SIGNED_ENVELOPE_TRANSPORT_TYPE);
let envelope: SignedEventEnvelopeV1 =
serde_json::from_value(got.data.expect("wrapper has data")).expect("parse envelope");
assert_eq!(envelope.algorithm, "hmac-sha256");
let verifying_keys: HashMap<String, cellos_core::TrustAnchorPublicKey> = HashMap::new();
let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
let inner = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
.expect("verify ok");
assert_eq!(inner.id, event.id);
}
#[tokio::test]
async fn post_sign_event_mutation_fails_verification() {
let capture = CaptureSink::new();
let signer_seed = [29u8; 32];
let signer = SigningKey::from_bytes(&signer_seed);
let sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
std::env::set_var("CELLOS_EVENT_SIGNING", "ed25519");
std::env::set_var("CELLOS_EVENT_SIGNING_KID", "ops-event-2026-q2");
std::env::set_var(
"CELLOS_EVENT_SIGNING_KEY_BASE64",
URL_SAFE_NO_PAD.encode(signer_seed),
);
SigningEventSink::from_env(capture.clone() as Arc<dyn EventSink>)
};
let event = sample_event();
sink.emit(&event).await.unwrap();
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
}
let got = capture.last().expect("wrapper emitted");
let mut envelope: SignedEventEnvelopeV1 =
serde_json::from_value(got.data.expect("wrapper has data")).expect("parse envelope");
envelope.event.id = "ev-tampered".into();
let mut keys = HashMap::new();
keys.insert(
"ops-event-2026-q2".to_string(),
cellos_core::TrustAnchorPublicKey::from_bytes_unchecked(
signer.verifying_key().to_bytes(),
),
);
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
.expect_err("post-sign mutation must fail verification");
assert!(format!("{err}").contains("ed25519 verify failed"));
}
#[tokio::test]
async fn from_env_missing_kid_disables_signing() {
let capture = CaptureSink::new();
let sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
std::env::set_var("CELLOS_EVENT_SIGNING", "ed25519");
std::env::set_var(
"CELLOS_EVENT_SIGNING_KEY_BASE64",
URL_SAFE_NO_PAD.encode([7u8; 32]),
);
SigningEventSink::from_env(capture.clone() as Arc<dyn EventSink>)
};
let event = sample_event();
sink.emit(&event).await.unwrap();
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signing_env();
}
let got = capture.last().expect("event emitted");
assert_eq!(
got.ty, "dev.cellos.events.cell.lifecycle.v1.started",
"missing kid must fall back to passthrough"
);
}
fn clear_signer_env() {
clear_signing_env();
std::env::remove_var("CELLOS_REDACT_EVENT_FIELDS");
}
#[tokio::test]
async fn standalone_signer_redacts_before_signing() {
let capture = CaptureSink::new();
let signer_seed = [41u8; 32];
let signer = SigningKey::from_bytes(&signer_seed);
let signer_sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signer_env();
std::env::set_var("CELLOS_EVENT_SIGNING", "ed25519");
std::env::set_var("CELLOS_EVENT_SIGNING_KID", "org-root-2026");
std::env::set_var(
"CELLOS_EVENT_SIGNING_KEY_BASE64",
URL_SAFE_NO_PAD.encode(signer_seed),
);
std::env::set_var("CELLOS_REDACT_EVENT_FIELDS", "reason");
StandaloneEventSigner::from_env(capture.clone() as Arc<dyn EventSink>).0
};
assert!(signer_sink.is_signing_enabled());
let event = CloudEventV1 {
data: Some(json!({"reason": "super-secret-db-password", "admitted": false})),
..sample_event()
};
let returned = signer_sink
.emit_decision(&event)
.await
.expect("emit_decision ok")
.expect("signed path returns the envelope");
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signer_env();
}
let inner_data = returned.event.data.as_ref().expect("inner data");
assert_eq!(
inner_data.get("reason").and_then(|v| v.as_str()),
Some("[redacted]"),
"secret field must be redacted in the signed event"
);
let mut keys = HashMap::new();
keys.insert(
"org-root-2026".to_string(),
cellos_core::TrustAnchorPublicKey::from_bytes_unchecked(
signer.verifying_key().to_bytes(),
),
);
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let verified =
verify_signed_event_envelope(&returned, &keys, &hmac_keys).expect("verify ok offline");
assert_eq!(
verified.data.as_ref().and_then(|d| d.get("reason")),
inner_data.get("reason"),
"verified payload is byte-identical to the signed (redacted) event"
);
}
#[tokio::test]
async fn standalone_signer_emits_verifiable_wrapper() {
let capture = CaptureSink::new();
let signer_seed = [53u8; 32];
let signer = SigningKey::from_bytes(&signer_seed);
let signer_sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signer_env();
std::env::set_var("CELLOS_EVENT_SIGNING", "ed25519");
std::env::set_var("CELLOS_EVENT_SIGNING_KID", "org-root-2026");
std::env::set_var(
"CELLOS_EVENT_SIGNING_KEY_BASE64",
URL_SAFE_NO_PAD.encode(signer_seed),
);
StandaloneEventSigner::from_env(capture.clone() as Arc<dyn EventSink>).0
};
let event = sample_event();
signer_sink.emit_decision(&event).await.expect("emit ok");
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signer_env();
}
let got = capture.last().expect("wrapper emitted on transport");
assert_eq!(got.ty, SIGNED_ENVELOPE_TRANSPORT_TYPE);
let mut envelope: SignedEventEnvelopeV1 =
serde_json::from_value(got.data.expect("wrapper data")).expect("parse envelope");
let mut keys = HashMap::new();
keys.insert(
"org-root-2026".to_string(),
cellos_core::TrustAnchorPublicKey::from_bytes_unchecked(
signer.verifying_key().to_bytes(),
),
);
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
verify_signed_event_envelope(&envelope, &keys, &hmac_keys).expect("verify ok");
envelope.event.id = "ev-tampered".into();
let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
.expect_err("tamper must fail verification");
assert!(format!("{err}").contains("ed25519 verify failed"));
}
#[tokio::test]
async fn standalone_signer_unconfigured_emits_signed_false() {
let capture = CaptureSink::new();
let signer_sink = {
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signer_env();
StandaloneEventSigner::from_env(capture.clone() as Arc<dyn EventSink>).0
};
assert!(!signer_sink.is_signing_enabled());
let event = sample_event();
let returned = signer_sink
.emit_decision(&event)
.await
.expect("emit_decision ok");
{
let _g = FROM_ENV_MUTEX.lock().unwrap();
clear_signer_env();
}
assert!(
returned.is_none(),
"unsigned advisory mode returns no offline-verifiable envelope"
);
let got = capture.last().expect("advisory record emitted");
assert_ne!(
got.ty, SIGNED_ENVELOPE_TRANSPORT_TYPE,
"advisory record is not a signed-envelope wrapper"
);
assert_eq!(
got.data
.as_ref()
.and_then(|d| d.get(SIGNED_MARKER_FIELD))
.and_then(|v| v.as_bool()),
Some(false),
"advisory record must carry signed:false"
);
}
}