use std::collections::HashMap;
use std::path::Path;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::crypto::{provider, TrustAnchorPublicKey};
use crate::error::CellosError;
use crate::types::CloudEventV1;
pub fn parse_trust_verify_keys(
raw: &str,
) -> Result<HashMap<String, TrustAnchorPublicKey>, CellosError> {
let value: Value = serde_json::from_str(raw).map_err(|e| {
CellosError::InvalidSpec(format!("trust verify keys: JSON parse error: {e}"))
})?;
let object = value.as_object().ok_or_else(|| {
CellosError::InvalidSpec(
"trust verify keys: top-level value must be a JSON object mapping kid -> base64url-pubkey".into(),
)
})?;
detect_duplicate_keys(raw)?;
let mut keys: HashMap<String, TrustAnchorPublicKey> = HashMap::with_capacity(object.len());
for (kid, value) in object {
let pubkey_b64 = value.as_str().ok_or_else(|| {
CellosError::InvalidSpec(format!(
"trust verify keys: value for kid {kid:?} must be a base64url string, got {value}"
))
})?;
let trimmed = pubkey_b64.trim_end_matches('=');
let bytes = URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
CellosError::InvalidSpec(format!(
"trust verify keys: kid {kid:?} value is not valid base64url: {e}"
))
})?;
let array: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
CellosError::InvalidSpec(format!(
"trust verify keys: kid {kid:?} decoded to {} bytes, expected 32",
bytes.len()
))
})?;
let public_key = TrustAnchorPublicKey::from_validated_bytes(array).map_err(|e| {
CellosError::InvalidSpec(format!(
"trust verify keys: kid {kid:?} is not a valid Ed25519 verifying key: {e}"
))
})?;
keys.insert(kid.clone(), public_key);
}
Ok(keys)
}
pub fn load_trust_verify_keys_file(
path: &Path,
) -> Result<HashMap<String, TrustAnchorPublicKey>, CellosError> {
let raw = read_trust_file_to_string(path)?;
parse_trust_verify_keys(&raw)
}
fn read_trust_file_to_string(path: &Path) -> Result<String, CellosError> {
#[cfg(unix)]
{
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
#[cfg(target_os = "linux")]
const O_NOFOLLOW: i32 = 0x20000;
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly",
))]
const O_NOFOLLOW: i32 = 0x100;
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly",
)))]
compile_error!(
"cellos-core::trust_keys: O_NOFOLLOW value not yet defined for this Unix target — \
add the platform-specific value (see <fcntl.h>) before building."
);
opts.custom_flags(O_NOFOLLOW);
let mut file = opts.open(path).map_err(|e| {
CellosError::InvalidSpec(format!("trust file: cannot open {}: {e}", path.display()))
})?;
let mut buf = String::new();
file.read_to_string(&mut buf).map_err(|e| {
CellosError::InvalidSpec(format!("trust file: cannot read {}: {e}", path.display()))
})?;
Ok(buf)
}
#[cfg(not(unix))]
{
#[cfg(windows)]
reject_reparse_point(path)?;
std::fs::read_to_string(path).map_err(|e| {
CellosError::InvalidSpec(format!("trust file: cannot read {}: {e}", path.display()))
})
}
}
pub fn load_revocation_list_file(
path: &Path,
verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
now: std::time::SystemTime,
) -> Result<std::collections::HashSet<String>, CellosError> {
let raw = read_trust_file_to_string(path)?;
let envelope: crate::types::SignedTrustKeysetEnvelope =
serde_json::from_str(&raw).map_err(|e| {
CellosError::InvalidSpec(format!(
"revocation list: {} is not a SignedTrustKeysetEnvelope: {e}",
path.display()
))
})?;
verify_and_parse_revocation_envelope(&envelope, verifying_keys, now)
}
#[cfg(windows)]
pub fn reject_reparse_point(path: &std::path::Path) -> Result<(), CellosError> {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
let meta = std::fs::symlink_metadata(path).map_err(|e| {
CellosError::InvalidSpec(format!("trust file: cannot stat {}: {e}", path.display()))
})?;
if meta.file_type().is_symlink() || (meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0
{
return Err(CellosError::InvalidSpec(format!(
"trust file: refusing {}: final path component is a reparse point \
(symlink/junction); best-effort Windows symlink-swap defense (TOCTOU residual)",
path.display()
)));
}
Ok(())
}
fn detect_duplicate_keys(raw: &str) -> Result<(), CellosError> {
use std::collections::HashSet;
let bytes = raw.as_bytes();
let mut seen: HashSet<String> = HashSet::new();
let mut idx = 0;
let mut depth: i32 = 0;
let mut in_string = false;
let mut after_colon_in_outer = false;
let mut current_key: Option<String> = None;
let mut escape = false;
let mut started = false;
while idx < bytes.len() {
let b = bytes[idx];
if in_string {
if escape {
escape = false;
if let Some(k) = current_key.as_mut() {
k.push(b as char);
}
idx += 1;
continue;
}
match b {
b'\\' => {
escape = true;
if let Some(k) = current_key.as_mut() {
k.push(b as char);
}
}
b'"' => {
in_string = false;
if depth == 1 && !after_colon_in_outer {
if let Some(key) = current_key.take() {
if !seen.insert(key.clone()) {
return Err(CellosError::InvalidSpec(format!(
"trust verify keys: duplicate kid {key:?} in keys file"
)));
}
}
} else {
let _ = current_key.take();
}
}
_ => {
if let Some(k) = current_key.as_mut() {
k.push(b as char);
}
}
}
idx += 1;
continue;
}
match b {
b'"' => {
in_string = true;
if depth == 1 && !after_colon_in_outer {
current_key = Some(String::new());
} else {
current_key = Some(String::new()); }
}
b'{' => {
depth += 1;
started = true;
}
b'}' => {
depth -= 1;
after_colon_in_outer = false;
if depth == 0 {
return Ok(());
}
}
b'[' => {
depth += 1;
}
b']' => {
depth -= 1;
}
b':' if depth == 1 => {
after_colon_in_outer = true;
}
b',' if depth == 1 => {
after_colon_in_outer = false;
}
_ => {}
}
idx += 1;
}
let _ = started;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedEventEnvelopeV1 {
pub event: CloudEventV1,
pub signer_kid: String,
pub algorithm: String,
pub signature: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_after: Option<String>,
}
pub fn canonical_event_signing_payload(event: &CloudEventV1) -> Result<Vec<u8>, CellosError> {
crate::canonical::canonical_payload(event)
}
pub fn sign_event_ed25519(
event: &CloudEventV1,
signer_kid: &str,
signing_key_seed: &[u8; 32],
) -> Result<SignedEventEnvelopeV1, CellosError> {
let payload = canonical_event_signing_payload(event)?;
let signature = provider().sign_ed25519(signing_key_seed, &payload)?;
Ok(SignedEventEnvelopeV1 {
event: event.clone(),
signer_kid: signer_kid.to_string(),
algorithm: "ed25519".to_string(),
signature: URL_SAFE_NO_PAD.encode(signature),
not_before: None,
not_after: None,
})
}
pub trait Signer: Send + Sync {
fn kid(&self) -> &str;
fn sign(&self, message: &[u8]) -> Result<[u8; 64], CellosError>;
fn verifying_key(&self) -> TrustAnchorPublicKey;
}
pub struct SoftwareSigner {
kid: String,
seed: zeroize::Zeroizing<[u8; 32]>,
public: TrustAnchorPublicKey,
}
impl SoftwareSigner {
pub fn from_seed(kid: impl Into<String>, seed: [u8; 32]) -> Result<Self, CellosError> {
let public = TrustAnchorPublicKey::from_bytes_unchecked(
crate::crypto::provider().public_key_from_seed(&seed)?,
);
Ok(Self {
kid: kid.into(),
seed: zeroize::Zeroizing::new(seed),
public,
})
}
}
impl Signer for SoftwareSigner {
fn kid(&self) -> &str {
&self.kid
}
fn sign(&self, message: &[u8]) -> Result<[u8; 64], CellosError> {
Ok(provider().sign_ed25519(&*self.seed, message)?)
}
fn verifying_key(&self) -> TrustAnchorPublicKey {
self.public
}
}
pub fn sign_event_with(
signer: &dyn Signer,
event: &CloudEventV1,
) -> Result<SignedEventEnvelopeV1, CellosError> {
let payload = canonical_event_signing_payload(event)?;
let signature = signer.sign(&payload)?;
Ok(SignedEventEnvelopeV1 {
event: event.clone(),
signer_kid: signer.kid().to_string(),
algorithm: "ed25519".to_string(),
signature: URL_SAFE_NO_PAD.encode(signature),
not_before: None,
not_after: None,
})
}
pub fn sign_event_hmac_sha256(
event: &CloudEventV1,
signer_kid: &str,
key_bytes: &[u8],
) -> Result<SignedEventEnvelopeV1, CellosError> {
let payload = canonical_event_signing_payload(event)?;
let mac = provider().hmac_sha256(key_bytes, &payload);
Ok(SignedEventEnvelopeV1 {
event: event.clone(),
signer_kid: signer_kid.to_string(),
algorithm: "hmac-sha256".to_string(),
signature: URL_SAFE_NO_PAD.encode(mac),
not_before: None,
not_after: None,
})
}
pub fn verify_signed_event_envelope_with_revocations<'a>(
envelope: &'a SignedEventEnvelopeV1,
verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
hmac_keys: &HashMap<String, Vec<u8>>,
revoked_kids: &std::collections::HashSet<String>,
) -> Result<&'a CloudEventV1, CellosError> {
if revoked_kids.contains(&envelope.signer_kid) {
return Err(CellosError::InvalidSpec(format!(
"signed event envelope: signer_kid_revoked: {:?}",
envelope.signer_kid
)));
}
verify_signed_event_envelope(envelope, verifying_keys, hmac_keys)
}
pub fn verify_and_parse_revocation_envelope(
envelope: &crate::types::SignedTrustKeysetEnvelope,
verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
now: std::time::SystemTime,
) -> Result<std::collections::HashSet<String>, CellosError> {
let payload_bytes =
crate::spec_validation::verify_signed_trust_keyset_envelope(envelope, verifying_keys, now)?;
if envelope.payload_type != crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE {
return Err(CellosError::InvalidSpec(format!(
"revocation list: expected payloadType {}, got '{}'",
crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE,
envelope.payload_type
)));
}
let list: crate::types::RevocationListV1 =
serde_json::from_slice(&payload_bytes).map_err(|e| {
CellosError::InvalidSpec(format!(
"revocation list: payload is not a RevocationListV1: {e}"
))
})?;
Ok(list.revocations.into_iter().map(|r| r.kid).collect())
}
pub fn verify_signed_event_envelope<'a>(
envelope: &'a SignedEventEnvelopeV1,
verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
hmac_keys: &HashMap<String, Vec<u8>>,
) -> Result<&'a CloudEventV1, CellosError> {
let payload = canonical_event_signing_payload(&envelope.event)?;
let sig_b64 = envelope.signature.trim_end_matches('=');
let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|e| {
CellosError::InvalidSpec(format!(
"signed event envelope: signature is not valid base64url: {e}"
))
})?;
match envelope.algorithm.as_str() {
"ed25519" => {
let verifying_key = verifying_keys.get(&envelope.signer_kid).ok_or_else(|| {
CellosError::InvalidSpec(format!(
"signed event envelope: unknown ed25519 signer kid {:?}",
envelope.signer_kid
))
})?;
if sig_bytes.len() != 64 {
return Err(CellosError::InvalidSpec(format!(
"signed event envelope: ed25519 signature must be 64 bytes, got {}",
sig_bytes.len()
)));
}
provider()
.verify_ed25519(verifying_key.as_bytes(), &payload, &sig_bytes)
.map_err(|e| {
CellosError::InvalidSpec(format!(
"signed event envelope: ed25519 verify failed: {e}"
))
})?;
Ok(&envelope.event)
}
"hmac-sha256" => {
let key = hmac_keys.get(&envelope.signer_kid).ok_or_else(|| {
CellosError::InvalidSpec(format!(
"signed event envelope: unknown hmac-sha256 signer kid {:?}",
envelope.signer_kid
))
})?;
if sig_bytes.len() != 32 {
return Err(CellosError::InvalidSpec(format!(
"signed event envelope: hmac-sha256 mac must be 32 bytes, got {}",
sig_bytes.len()
)));
}
let expected = provider().hmac_sha256(key, &payload);
if !provider().constant_time_eq(&expected, &sig_bytes) {
return Err(CellosError::InvalidSpec(
"signed event envelope: hmac-sha256 verify failed".into(),
));
}
Ok(&envelope.event)
}
other => Err(CellosError::InvalidSpec(format!(
"signed event envelope: unknown algorithm {other:?} (expected ed25519 or hmac-sha256)"
))),
}
}
#[cfg(test)]
mod tests {
use super::{load_trust_verify_keys_file, parse_trust_verify_keys};
use crate::crypto::{provider, TrustAnchorPublicKey};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use std::io::Write;
fn seed_bytes(seed: u8) -> [u8; 32] {
[seed; 32]
}
fn anchor(seed: u8) -> TrustAnchorPublicKey {
TrustAnchorPublicKey::from_bytes_unchecked(
provider()
.public_key_from_seed(&seed_bytes(seed))
.expect("derive pub"),
)
}
fn pubkey_b64(seed: u8) -> String {
URL_SAFE_NO_PAD.encode(
provider()
.public_key_from_seed(&seed_bytes(seed))
.expect("derive pub"),
)
}
#[test]
fn parses_well_formed_two_key_map() {
let raw = format!(
r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q3": "{}" }}"#,
pubkey_b64(7),
pubkey_b64(11)
);
let keys = parse_trust_verify_keys(&raw).expect("well-formed map must parse");
assert_eq!(keys.len(), 2);
assert!(keys.contains_key("ops-envelope-2026-q2"));
assert!(keys.contains_key("ops-envelope-2026-q3"));
assert_eq!(
keys["ops-envelope-2026-q2"],
anchor(7),
"kid q2 must round-trip to its source verifying key"
);
}
#[test]
fn rejects_duplicate_kid() {
let raw = format!(
r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q2": "{}" }}"#,
pubkey_b64(7),
pubkey_b64(11)
);
let err = parse_trust_verify_keys(&raw).expect_err("duplicate kid must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("duplicate kid"),
"expected duplicate-kid error, got: {msg}"
);
}
#[test]
fn rejects_malformed_base64() {
let raw = r#"{ "ops-bad": "@@@not-base64@@@" }"#;
let err = parse_trust_verify_keys(raw).expect_err("malformed base64 must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("not valid base64url"),
"expected base64-decode error, got: {msg}"
);
}
#[test]
fn rejects_wrong_length_pubkey() {
let too_short = URL_SAFE_NO_PAD.encode([0u8; 16]);
let raw = format!(r#"{{ "ops-short": "{too_short}" }}"#);
let err = parse_trust_verify_keys(&raw).expect_err("16-byte pubkey must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("expected 32"),
"expected 32-byte length error, got: {msg}"
);
}
#[test]
fn empty_object_is_accepted() {
let raw = "{}";
let keys = parse_trust_verify_keys(raw).expect("empty object is the no-keys case");
assert!(keys.is_empty());
}
#[test]
fn missing_file_errors() {
let path = std::path::Path::new("/nonexistent/path/that/should/not/exist.json");
let err =
load_trust_verify_keys_file(path).expect_err("missing file must surface an error");
let msg = format!("{err}");
assert!(
msg.contains("cannot") && msg.contains("nonexistent"),
"expected file-open error, got: {msg}"
);
}
#[test]
fn rejects_non_utf8_input() {
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("trust-keys-non-utf8.json");
let mut f = std::fs::File::create(&path).expect("create");
f.write_all(&[0xFF, 0xFE, 0xFD, 0xFC]).expect("write");
drop(f);
let err = load_trust_verify_keys_file(&path).expect_err("non-utf8 must error");
let msg = format!("{err}");
assert!(
msg.contains("cannot read") || msg.contains("utf-8") || msg.contains("UTF-8"),
"expected non-utf8 read error, got: {msg}"
);
}
#[test]
fn rejects_top_level_non_object() {
let raw = r#"["not", "an", "object"]"#;
let err = parse_trust_verify_keys(raw).expect_err("top-level non-object must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("must be a JSON object"),
"expected top-level-object error, got: {msg}"
);
}
#[test]
fn loads_valid_file_via_load_helper() {
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("trust-keys.json");
let raw = format!(
r#"{{ "kid-active-7": "{}", "kid-active-11": "{}" }}"#,
pubkey_b64(7),
pubkey_b64(11)
);
std::fs::write(&path, raw).expect("write keys");
let keys = load_trust_verify_keys_file(&path).expect("load via helper");
assert_eq!(keys.len(), 2);
assert_eq!(keys["kid-active-7"], anchor(7));
}
#[cfg(windows)]
#[test]
fn load_helper_rejects_reparse_point_at_final_component_windows() {
let dir = tempfile::tempdir().expect("tmpdir");
let real_path = dir.path().join("trust-keys-real.json");
let link_path = dir.path().join("trust-keys-link.json");
let raw = format!(r#"{{ "kid-only-1": "{}" }}"#, pubkey_b64(7));
std::fs::write(&real_path, raw).expect("write real keys file");
load_trust_verify_keys_file(&real_path).expect("real path loads");
if std::os::windows::fs::symlink_file(&real_path, &link_path).is_err() {
eprintln!(
"load_helper_rejects_reparse_point_at_final_component_windows: skipping — \
cannot create a symlink (no SeCreateSymbolicLinkPrivilege / Developer Mode)"
);
return;
}
let err = load_trust_verify_keys_file(&link_path)
.expect_err("a reparse point at the final component must be rejected");
assert!(
format!("{err}").contains("reparse point"),
"expected reparse-point rejection, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn load_helper_rejects_symlink_at_final_component() {
let dir = tempfile::tempdir().expect("tmpdir");
let real_path = dir.path().join("trust-keys-real.json");
let symlink_path = dir.path().join("trust-keys-symlink.json");
let raw = format!(r#"{{ "kid-only-1": "{}" }}"#, pubkey_b64(7));
std::fs::write(&real_path, raw).expect("write real keys file");
std::os::unix::fs::symlink(&real_path, &symlink_path).expect("create symlink");
load_trust_verify_keys_file(&real_path).expect("real path loads");
let err = load_trust_verify_keys_file(&symlink_path)
.expect_err("symlink at final component must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("cannot open"),
"expected open-side rejection, got: {msg}"
);
}
use super::{
canonical_event_signing_payload, sign_event_ed25519, sign_event_hmac_sha256,
sign_event_with, verify_signed_event_envelope, Signer, SoftwareSigner,
};
use crate::types::CloudEventV1;
use std::collections::HashMap;
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(serde_json::json!({"cellId": "test-cell-1"})),
time: Some("2026-05-06T12:00:00Z".into()),
traceparent: None,
cex: None,
}
}
#[test]
fn ed25519_round_trip_verifies() {
let seed = seed_bytes(31);
let event = sample_event();
let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
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(), anchor(31));
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let verified =
verify_signed_event_envelope(&envelope, &keys, &hmac_keys).expect("verify ok");
assert_eq!(verified.id, event.id);
}
#[test]
fn ed25519_tampered_event_fails_verify() {
let seed = seed_bytes(31);
let event = sample_event();
let mut envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
envelope.event.id = "ev-tampered".into();
let mut keys = HashMap::new();
keys.insert("ops-event-2026-q2".to_string(), anchor(31));
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
.expect_err("tampered event must fail verify");
assert!(format!("{err}").contains("ed25519 verify failed"));
}
#[test]
fn ed25519_unknown_kid_fails_verify() {
let seed = seed_bytes(31);
let event = sample_event();
let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
let keys: HashMap<String, TrustAnchorPublicKey> = HashMap::new();
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
.expect_err("unknown kid must fail");
assert!(format!("{err}").contains("unknown ed25519 signer kid"));
}
#[test]
fn software_signer_matches_sign_event_ed25519_and_verifies() {
let seed = seed_bytes(31);
let event = sample_event();
let kid = "ops-event-2026-q2";
let legacy = sign_event_ed25519(&event, kid, &seed).expect("legacy sign");
let signer = SoftwareSigner::from_seed(kid, seed).expect("build signer");
let via_seam = sign_event_with(&signer, &event).expect("seam sign");
assert_eq!(
serde_json::to_vec(&legacy).unwrap(),
serde_json::to_vec(&via_seam).unwrap(),
"SoftwareSigner must produce a byte-identical envelope to sign_event_ed25519"
);
assert_eq!(signer.kid(), kid);
assert_eq!(signer.verifying_key(), anchor(31));
let mut keys = HashMap::new();
keys.insert(kid.to_string(), signer.verifying_key());
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let verified =
verify_signed_event_envelope(&via_seam, &keys, &hmac_keys).expect("verify ok");
assert_eq!(verified.id, event.id);
}
#[test]
fn revocation_list_revokes_kid_and_fails_closed_on_tamper() {
use crate::crypto::{provider, TrustAnchorPublicKey};
use crate::types::{
RevocationListV1, RevokedSigner, SignedTrustKeysetEnvelope, TrustKeysetSignature,
TRUST_REVOCATION_V1_PAYLOAD_TYPE,
};
use crate::{
verify_and_parse_revocation_envelope, verify_signed_event_envelope_with_revocations,
};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use std::collections::HashSet;
use std::fmt::Write as _;
use std::time::SystemTime;
let org_seed = seed_bytes(42);
let mut org_keys = HashMap::new();
org_keys.insert(
"org-root-2026".to_string(),
TrustAnchorPublicKey::from_bytes_unchecked(
provider().public_key_from_seed(&org_seed).unwrap(),
),
);
let list = RevocationListV1 {
schema_version: "1.0.0".into(),
revocation_epoch: 1,
revocations: vec![RevokedSigner {
kid: "kid-bad".into(),
revoked_at: "2026-06-23T00:00:00Z".into(),
reason: "key_compromise".into(),
}],
};
let payload_bytes = serde_json::to_vec(&list).unwrap();
let sig = provider().sign_ed25519(&org_seed, &payload_bytes).unwrap();
let mut digest_hex = String::from("sha256:");
for b in provider().sha256(&payload_bytes) {
write!(digest_hex, "{b:02x}").unwrap();
}
let make_env = |payload_b64: String, digest: String| SignedTrustKeysetEnvelope {
schema_version: "1.0.0".into(),
payload_type: TRUST_REVOCATION_V1_PAYLOAD_TYPE.into(),
payload: payload_b64,
signatures: vec![TrustKeysetSignature {
signer_kid: "org-root-2026".into(),
algorithm: "ed25519".into(),
signature: URL_SAFE_NO_PAD.encode(sig),
not_before: None,
not_after: None,
}],
payload_digest: digest,
produced_at: "2026-06-23T00:00:00Z".into(),
replaces_envelope_digest: None,
required_signer_count: None,
};
let envelope = make_env(URL_SAFE_NO_PAD.encode(&payload_bytes), digest_hex.clone());
let revoked: HashSet<String> =
verify_and_parse_revocation_envelope(&envelope, &org_keys, SystemTime::now())
.expect("revocation envelope must verify under org-root");
assert!(revoked.contains("kid-bad"));
let event = sample_event();
let bad_env = sign_event_ed25519(&event, "kid-bad", &seed_bytes(7)).unwrap();
let mut event_keys = HashMap::new();
event_keys.insert("kid-bad".to_string(), anchor(7));
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let empty: HashSet<String> = HashSet::new();
verify_signed_event_envelope_with_revocations(&bad_env, &event_keys, &hmac_keys, &empty)
.expect("unrevoked kid with a valid signature must verify");
let err = verify_signed_event_envelope_with_revocations(
&bad_env,
&event_keys,
&hmac_keys,
&revoked,
)
.expect_err("a revoked kid must be rejected even with a valid signature");
assert!(
format!("{err}").contains("signer_kid_revoked"),
"expected signer_kid_revoked, got: {err}"
);
let tampered = make_env(
URL_SAFE_NO_PAD
.encode(br#"{"schemaVersion":"1.0.0","revocationEpoch":2,"revocations":[]}"#),
digest_hex,
);
let err = verify_and_parse_revocation_envelope(&tampered, &org_keys, SystemTime::now())
.expect_err("a tampered revocation envelope must fail closed");
assert!(
format!("{err}").contains("digest mismatch") || format!("{err}").contains("verify"),
"expected fail-closed digest/verify error, got: {err}"
);
}
#[test]
fn hmac_sha256_round_trip_verifies() {
let key = b"super-secret-shared-symmetric-key";
let event = sample_event();
let envelope = sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
assert_eq!(envelope.algorithm, "hmac-sha256");
let verifying_keys: HashMap<String, _> = 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 verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
.expect("verify ok");
assert_eq!(verified.id, event.id);
}
#[test]
fn hmac_sha256_tampered_event_fails_verify() {
let key = b"super-secret-shared-symmetric-key";
let event = sample_event();
let mut envelope =
sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
envelope.event.ty = "dev.cellos.events.cell.lifecycle.v1.destroyed".into();
let verifying_keys: HashMap<String, _> = 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 err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
.expect_err("tampered event must fail");
assert!(format!("{err}").contains("hmac-sha256 verify failed"));
}
#[test]
fn unknown_algorithm_rejected() {
let seed = seed_bytes(31);
let event = sample_event();
let mut envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
envelope.algorithm = "rsa-pss-sha512".into();
let verifying_keys: HashMap<String, _> = HashMap::new();
let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
.expect_err("unknown algorithm must be rejected");
assert!(format!("{err}").contains("unknown algorithm"));
}
#[test]
fn canonical_payload_is_deterministic() {
let event = sample_event();
let a = canonical_event_signing_payload(&event).expect("a");
let b = canonical_event_signing_payload(&event).expect("b");
assert_eq!(a, b, "canonical signing payload must be byte-identical");
}
}