use std::collections::HashMap;
use std::path::Path;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::error::CellosError;
use crate::types::CloudEventV1;
pub fn parse_trust_verify_keys(raw: &str) -> Result<HashMap<String, VerifyingKey>, 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, VerifyingKey> = 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 verifying_key = VerifyingKey::from_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(), verifying_key);
}
Ok(keys)
}
pub fn load_trust_verify_keys_file(
path: &Path,
) -> Result<HashMap<String, VerifyingKey>, CellosError> {
#[cfg(unix)]
let raw = {
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 verify keys: cannot open {}: {e}",
path.display()
))
})?;
let mut buf = String::new();
file.read_to_string(&mut buf).map_err(|e| {
CellosError::InvalidSpec(format!(
"trust verify keys: cannot read {}: {e}",
path.display()
))
})?;
buf
};
#[cfg(not(unix))]
let raw = std::fs::read_to_string(path).map_err(|e| {
CellosError::InvalidSpec(format!(
"trust verify keys: cannot read {}: {e}",
path.display()
))
})?;
parse_trust_verify_keys(&raw)
}
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> {
serde_json::to_vec(event).map_err(|e| {
CellosError::InvalidSpec(format!("canonical_event_signing_payload: serialize: {e}"))
})
}
pub fn sign_event_ed25519(
event: &CloudEventV1,
signer_kid: &str,
signing_key: &SigningKey,
) -> Result<SignedEventEnvelopeV1, CellosError> {
use ed25519_dalek::Signer;
let payload = canonical_event_signing_payload(event)?;
let signature = signing_key.sign(&payload);
Ok(SignedEventEnvelopeV1 {
event: event.clone(),
signer_kid: signer_kid.to_string(),
algorithm: "ed25519".to_string(),
signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
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 = 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<'a>(
envelope: &'a SignedEventEnvelopeV1,
verifying_keys: &HashMap<String, VerifyingKey>,
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
))
})?;
let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
CellosError::InvalidSpec(format!(
"signed event envelope: ed25519 signature must be 64 bytes, got {}",
sig_bytes.len()
))
})?;
let signature = Signature::from_bytes(&sig_array);
verifying_key
.verify_strict(&payload, &signature)
.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 = hmac_sha256(key, &payload);
if !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)"
))),
}
}
fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut block_key = [0u8; BLOCK];
if key.len() > BLOCK {
let mut hasher = Sha256::new();
hasher.update(key);
let digest = hasher.finalize();
block_key[..32].copy_from_slice(&digest);
} else {
block_key[..key.len()].copy_from_slice(key);
}
let mut ipad = [0u8; BLOCK];
let mut opad = [0u8; BLOCK];
for i in 0..BLOCK {
ipad[i] = block_key[i] ^ 0x36;
opad[i] = block_key[i] ^ 0x5c;
}
let mut inner = Sha256::new();
inner.update(ipad);
inner.update(message);
let inner_digest = inner.finalize();
let mut outer = Sha256::new();
outer.update(opad);
outer.update(inner_digest);
let mac = outer.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&mac);
out
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::{load_trust_verify_keys_file, parse_trust_verify_keys};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::SigningKey;
use std::io::Write;
fn signing_key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn pubkey_b64(seed: u8) -> String {
let signer = signing_key(seed);
URL_SAFE_NO_PAD.encode(signer.verifying_key().to_bytes())
}
#[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"],
signing_key(7).verifying_key(),
"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"], signing_key(7).verifying_key());
}
#[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,
verify_signed_event_envelope,
};
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,
}
}
#[test]
fn ed25519_round_trip_verifies() {
let signer = signing_key(31);
let event = sample_event();
let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &signer).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(), signer.verifying_key());
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 signer = signing_key(31);
let event = sample_event();
let mut envelope =
sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
envelope.event.id = "ev-tampered".into();
let mut keys = HashMap::new();
keys.insert("ops-event-2026-q2".to_string(), signer.verifying_key());
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 signer = signing_key(31);
let event = sample_event();
let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
let keys: HashMap<String, _> = 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 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 signer = signing_key(31);
let event = sample_event();
let mut envelope =
sign_event_ed25519(&event, "ops-event-2026-q2", &signer).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");
}
}