1use std::collections::HashMap;
41use std::path::Path;
42
43use base64::engine::general_purpose::URL_SAFE_NO_PAD;
44use base64::Engine as _;
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47
48use crate::crypto::{provider, TrustAnchorPublicKey};
49use crate::error::CellosError;
50use crate::types::CloudEventV1;
51
52pub fn parse_trust_verify_keys(
75 raw: &str,
76) -> Result<HashMap<String, TrustAnchorPublicKey>, CellosError> {
77 let value: Value = serde_json::from_str(raw).map_err(|e| {
78 CellosError::InvalidSpec(format!("trust verify keys: JSON parse error: {e}"))
79 })?;
80
81 let object = value.as_object().ok_or_else(|| {
82 CellosError::InvalidSpec(
83 "trust verify keys: top-level value must be a JSON object mapping kid -> base64url-pubkey".into(),
84 )
85 })?;
86
87 detect_duplicate_keys(raw)?;
92
93 let mut keys: HashMap<String, TrustAnchorPublicKey> = HashMap::with_capacity(object.len());
94 for (kid, value) in object {
95 let pubkey_b64 = value.as_str().ok_or_else(|| {
96 CellosError::InvalidSpec(format!(
97 "trust verify keys: value for kid {kid:?} must be a base64url string, got {value}"
98 ))
99 })?;
100
101 let trimmed = pubkey_b64.trim_end_matches('=');
103 let bytes = URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
104 CellosError::InvalidSpec(format!(
105 "trust verify keys: kid {kid:?} value is not valid base64url: {e}"
106 ))
107 })?;
108
109 let array: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
110 CellosError::InvalidSpec(format!(
111 "trust verify keys: kid {kid:?} decoded to {} bytes, expected 32",
112 bytes.len()
113 ))
114 })?;
115
116 let public_key = TrustAnchorPublicKey::from_validated_bytes(array).map_err(|e| {
120 CellosError::InvalidSpec(format!(
121 "trust verify keys: kid {kid:?} is not a valid Ed25519 verifying key: {e}"
122 ))
123 })?;
124
125 keys.insert(kid.clone(), public_key);
126 }
127
128 Ok(keys)
129}
130
131pub fn load_trust_verify_keys_file(
146 path: &Path,
147) -> Result<HashMap<String, TrustAnchorPublicKey>, CellosError> {
148 let raw = read_trust_file_to_string(path)?;
149 parse_trust_verify_keys(&raw)
150}
151
152fn read_trust_file_to_string(path: &Path) -> Result<String, CellosError> {
159 #[cfg(unix)]
160 {
161 use std::io::Read;
162 use std::os::unix::fs::OpenOptionsExt;
163 let mut opts = std::fs::OpenOptions::new();
164 opts.read(true);
165 #[cfg(target_os = "linux")]
175 const O_NOFOLLOW: i32 = 0x20000;
176 #[cfg(any(
177 target_os = "macos",
178 target_os = "ios",
179 target_os = "freebsd",
180 target_os = "netbsd",
181 target_os = "openbsd",
182 target_os = "dragonfly",
183 ))]
184 const O_NOFOLLOW: i32 = 0x100;
185 #[cfg(not(any(
188 target_os = "linux",
189 target_os = "macos",
190 target_os = "ios",
191 target_os = "freebsd",
192 target_os = "netbsd",
193 target_os = "openbsd",
194 target_os = "dragonfly",
195 )))]
196 compile_error!(
197 "cellos-core::trust_keys: O_NOFOLLOW value not yet defined for this Unix target — \
198 add the platform-specific value (see <fcntl.h>) before building."
199 );
200 opts.custom_flags(O_NOFOLLOW);
201 let mut file = opts.open(path).map_err(|e| {
202 CellosError::InvalidSpec(format!("trust file: cannot open {}: {e}", path.display()))
203 })?;
204 let mut buf = String::new();
205 file.read_to_string(&mut buf).map_err(|e| {
206 CellosError::InvalidSpec(format!("trust file: cannot read {}: {e}", path.display()))
207 })?;
208 Ok(buf)
209 }
210 #[cfg(not(unix))]
211 {
212 #[cfg(windows)]
213 reject_reparse_point(path)?;
214 std::fs::read_to_string(path).map_err(|e| {
215 CellosError::InvalidSpec(format!("trust file: cannot read {}: {e}", path.display()))
216 })
217 }
218}
219
220pub fn load_revocation_list_file(
230 path: &Path,
231 verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
232 now: std::time::SystemTime,
233) -> Result<std::collections::HashSet<String>, CellosError> {
234 let raw = read_trust_file_to_string(path)?;
235 let envelope: crate::types::SignedTrustKeysetEnvelope =
236 serde_json::from_str(&raw).map_err(|e| {
237 CellosError::InvalidSpec(format!(
238 "revocation list: {} is not a SignedTrustKeysetEnvelope: {e}",
239 path.display()
240 ))
241 })?;
242 verify_and_parse_revocation_envelope(&envelope, verifying_keys, now)
243}
244
245#[cfg(windows)]
257pub fn reject_reparse_point(path: &std::path::Path) -> Result<(), CellosError> {
258 use std::os::windows::fs::MetadataExt;
259 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
261 let meta = std::fs::symlink_metadata(path).map_err(|e| {
262 CellosError::InvalidSpec(format!("trust file: cannot stat {}: {e}", path.display()))
263 })?;
264 if meta.file_type().is_symlink() || (meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0
265 {
266 return Err(CellosError::InvalidSpec(format!(
267 "trust file: refusing {}: final path component is a reparse point \
268 (symlink/junction); best-effort Windows symlink-swap defense (TOCTOU residual)",
269 path.display()
270 )));
271 }
272 Ok(())
273}
274
275fn detect_duplicate_keys(raw: &str) -> Result<(), CellosError> {
291 use std::collections::HashSet;
292
293 let bytes = raw.as_bytes();
294 let mut seen: HashSet<String> = HashSet::new();
295 let mut idx = 0;
296 let mut depth: i32 = 0;
297 let mut in_string = false;
298 let mut after_colon_in_outer = false;
299 let mut current_key: Option<String> = None;
300 let mut escape = false;
301 let mut started = false;
302
303 while idx < bytes.len() {
304 let b = bytes[idx];
305 if in_string {
306 if escape {
307 escape = false;
308 if let Some(k) = current_key.as_mut() {
309 k.push(b as char);
310 }
311 idx += 1;
312 continue;
313 }
314 match b {
315 b'\\' => {
316 escape = true;
317 if let Some(k) = current_key.as_mut() {
318 k.push(b as char);
319 }
320 }
321 b'"' => {
322 in_string = false;
323 if depth == 1 && !after_colon_in_outer {
324 if let Some(key) = current_key.take() {
325 if !seen.insert(key.clone()) {
326 return Err(CellosError::InvalidSpec(format!(
327 "trust verify keys: duplicate kid {key:?} in keys file"
328 )));
329 }
330 }
331 } else {
332 let _ = current_key.take();
334 }
335 }
336 _ => {
337 if let Some(k) = current_key.as_mut() {
338 k.push(b as char);
339 }
340 }
341 }
342 idx += 1;
343 continue;
344 }
345
346 match b {
347 b'"' => {
348 in_string = true;
349 if depth == 1 && !after_colon_in_outer {
352 current_key = Some(String::new());
353 } else {
354 current_key = Some(String::new()); }
358 }
359 b'{' => {
360 depth += 1;
361 started = true;
362 }
363 b'}' => {
364 depth -= 1;
365 after_colon_in_outer = false;
366 if depth == 0 {
367 return Ok(());
368 }
369 }
370 b'[' => {
371 depth += 1;
372 }
373 b']' => {
374 depth -= 1;
375 }
376 b':' if depth == 1 => {
377 after_colon_in_outer = true;
378 }
379 b',' if depth == 1 => {
380 after_colon_in_outer = false;
381 }
382 _ => {}
383 }
384 idx += 1;
385 }
386
387 let _ = started;
391 Ok(())
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase")]
417pub struct SignedEventEnvelopeV1 {
418 pub event: CloudEventV1,
419 pub signer_kid: String,
420 pub algorithm: String,
421 pub signature: String,
422 #[serde(skip_serializing_if = "Option::is_none")]
423 pub not_before: Option<String>,
424 #[serde(skip_serializing_if = "Option::is_none")]
425 pub not_after: Option<String>,
426}
427
428pub fn canonical_event_signing_payload(event: &CloudEventV1) -> Result<Vec<u8>, CellosError> {
438 crate::canonical::canonical_payload(event)
439}
440
441pub fn sign_event_ed25519(
448 event: &CloudEventV1,
449 signer_kid: &str,
450 signing_key_seed: &[u8; 32],
451) -> Result<SignedEventEnvelopeV1, CellosError> {
452 let payload = canonical_event_signing_payload(event)?;
453 let signature = provider().sign_ed25519(signing_key_seed, &payload)?;
454 Ok(SignedEventEnvelopeV1 {
455 event: event.clone(),
456 signer_kid: signer_kid.to_string(),
457 algorithm: "ed25519".to_string(),
458 signature: URL_SAFE_NO_PAD.encode(signature),
459 not_before: None,
460 not_after: None,
461 })
462}
463
464pub trait Signer: Send + Sync {
471 fn kid(&self) -> &str;
473
474 fn sign(&self, message: &[u8]) -> Result<[u8; 64], CellosError>;
480
481 fn verifying_key(&self) -> TrustAnchorPublicKey;
483}
484
485pub struct SoftwareSigner {
491 kid: String,
492 seed: zeroize::Zeroizing<[u8; 32]>,
493 public: TrustAnchorPublicKey,
494}
495
496impl SoftwareSigner {
497 pub fn from_seed(kid: impl Into<String>, seed: [u8; 32]) -> Result<Self, CellosError> {
504 let public = TrustAnchorPublicKey::from_bytes_unchecked(
507 crate::crypto::provider().public_key_from_seed(&seed)?,
508 );
509 Ok(Self {
510 kid: kid.into(),
511 seed: zeroize::Zeroizing::new(seed),
512 public,
513 })
514 }
515}
516
517impl Signer for SoftwareSigner {
518 fn kid(&self) -> &str {
519 &self.kid
520 }
521
522 fn sign(&self, message: &[u8]) -> Result<[u8; 64], CellosError> {
523 Ok(provider().sign_ed25519(&*self.seed, message)?)
524 }
525
526 fn verifying_key(&self) -> TrustAnchorPublicKey {
527 self.public
528 }
529}
530
531pub fn sign_event_with(
539 signer: &dyn Signer,
540 event: &CloudEventV1,
541) -> Result<SignedEventEnvelopeV1, CellosError> {
542 let payload = canonical_event_signing_payload(event)?;
543 let signature = signer.sign(&payload)?;
544 Ok(SignedEventEnvelopeV1 {
545 event: event.clone(),
546 signer_kid: signer.kid().to_string(),
547 algorithm: "ed25519".to_string(),
548 signature: URL_SAFE_NO_PAD.encode(signature),
549 not_before: None,
550 not_after: None,
551 })
552}
553
554pub fn sign_event_hmac_sha256(
556 event: &CloudEventV1,
557 signer_kid: &str,
558 key_bytes: &[u8],
559) -> Result<SignedEventEnvelopeV1, CellosError> {
560 let payload = canonical_event_signing_payload(event)?;
561 let mac = provider().hmac_sha256(key_bytes, &payload);
562 Ok(SignedEventEnvelopeV1 {
563 event: event.clone(),
564 signer_kid: signer_kid.to_string(),
565 algorithm: "hmac-sha256".to_string(),
566 signature: URL_SAFE_NO_PAD.encode(mac),
567 not_before: None,
568 not_after: None,
569 })
570}
571
572pub fn verify_signed_event_envelope_with_revocations<'a>(
578 envelope: &'a SignedEventEnvelopeV1,
579 verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
580 hmac_keys: &HashMap<String, Vec<u8>>,
581 revoked_kids: &std::collections::HashSet<String>,
582) -> Result<&'a CloudEventV1, CellosError> {
583 if revoked_kids.contains(&envelope.signer_kid) {
584 return Err(CellosError::InvalidSpec(format!(
585 "signed event envelope: signer_kid_revoked: {:?}",
586 envelope.signer_kid
587 )));
588 }
589 verify_signed_event_envelope(envelope, verifying_keys, hmac_keys)
590}
591
592pub fn verify_and_parse_revocation_envelope(
603 envelope: &crate::types::SignedTrustKeysetEnvelope,
604 verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
605 now: std::time::SystemTime,
606) -> Result<std::collections::HashSet<String>, CellosError> {
607 let payload_bytes =
608 crate::spec_validation::verify_signed_trust_keyset_envelope(envelope, verifying_keys, now)?;
609 if envelope.payload_type != crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE {
610 return Err(CellosError::InvalidSpec(format!(
611 "revocation list: expected payloadType {}, got '{}'",
612 crate::types::TRUST_REVOCATION_V1_PAYLOAD_TYPE,
613 envelope.payload_type
614 )));
615 }
616 let list: crate::types::RevocationListV1 =
617 serde_json::from_slice(&payload_bytes).map_err(|e| {
618 CellosError::InvalidSpec(format!(
619 "revocation list: payload is not a RevocationListV1: {e}"
620 ))
621 })?;
622 Ok(list.revocations.into_iter().map(|r| r.kid).collect())
623}
624
625pub fn verify_signed_event_envelope<'a>(
627 envelope: &'a SignedEventEnvelopeV1,
628 verifying_keys: &HashMap<String, TrustAnchorPublicKey>,
629 hmac_keys: &HashMap<String, Vec<u8>>,
630) -> Result<&'a CloudEventV1, CellosError> {
631 let payload = canonical_event_signing_payload(&envelope.event)?;
632 let sig_b64 = envelope.signature.trim_end_matches('=');
633 let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|e| {
634 CellosError::InvalidSpec(format!(
635 "signed event envelope: signature is not valid base64url: {e}"
636 ))
637 })?;
638
639 match envelope.algorithm.as_str() {
640 "ed25519" => {
641 let verifying_key = verifying_keys.get(&envelope.signer_kid).ok_or_else(|| {
642 CellosError::InvalidSpec(format!(
643 "signed event envelope: unknown ed25519 signer kid {:?}",
644 envelope.signer_kid
645 ))
646 })?;
647 if sig_bytes.len() != 64 {
648 return Err(CellosError::InvalidSpec(format!(
649 "signed event envelope: ed25519 signature must be 64 bytes, got {}",
650 sig_bytes.len()
651 )));
652 }
653 provider()
654 .verify_ed25519(verifying_key.as_bytes(), &payload, &sig_bytes)
655 .map_err(|e| {
656 CellosError::InvalidSpec(format!(
657 "signed event envelope: ed25519 verify failed: {e}"
658 ))
659 })?;
660 Ok(&envelope.event)
661 }
662 "hmac-sha256" => {
663 let key = hmac_keys.get(&envelope.signer_kid).ok_or_else(|| {
664 CellosError::InvalidSpec(format!(
665 "signed event envelope: unknown hmac-sha256 signer kid {:?}",
666 envelope.signer_kid
667 ))
668 })?;
669 if sig_bytes.len() != 32 {
670 return Err(CellosError::InvalidSpec(format!(
671 "signed event envelope: hmac-sha256 mac must be 32 bytes, got {}",
672 sig_bytes.len()
673 )));
674 }
675 let expected = provider().hmac_sha256(key, &payload);
676 if !provider().constant_time_eq(&expected, &sig_bytes) {
677 return Err(CellosError::InvalidSpec(
678 "signed event envelope: hmac-sha256 verify failed".into(),
679 ));
680 }
681 Ok(&envelope.event)
682 }
683 other => Err(CellosError::InvalidSpec(format!(
684 "signed event envelope: unknown algorithm {other:?} (expected ed25519 or hmac-sha256)"
685 ))),
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::{load_trust_verify_keys_file, parse_trust_verify_keys};
692 use crate::crypto::{provider, TrustAnchorPublicKey};
693 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
694 use base64::Engine as _;
695 use std::io::Write;
696
697 fn seed_bytes(seed: u8) -> [u8; 32] {
699 [seed; 32]
700 }
701
702 fn anchor(seed: u8) -> TrustAnchorPublicKey {
704 TrustAnchorPublicKey::from_bytes_unchecked(
705 provider()
706 .public_key_from_seed(&seed_bytes(seed))
707 .expect("derive pub"),
708 )
709 }
710
711 fn pubkey_b64(seed: u8) -> String {
712 URL_SAFE_NO_PAD.encode(
713 provider()
714 .public_key_from_seed(&seed_bytes(seed))
715 .expect("derive pub"),
716 )
717 }
718
719 #[test]
720 fn parses_well_formed_two_key_map() {
721 let raw = format!(
722 r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q3": "{}" }}"#,
723 pubkey_b64(7),
724 pubkey_b64(11)
725 );
726 let keys = parse_trust_verify_keys(&raw).expect("well-formed map must parse");
727 assert_eq!(keys.len(), 2);
728 assert!(keys.contains_key("ops-envelope-2026-q2"));
729 assert!(keys.contains_key("ops-envelope-2026-q3"));
730 assert_eq!(
731 keys["ops-envelope-2026-q2"],
732 anchor(7),
733 "kid q2 must round-trip to its source verifying key"
734 );
735 }
736
737 #[test]
738 fn rejects_duplicate_kid() {
739 let raw = format!(
740 r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q2": "{}" }}"#,
741 pubkey_b64(7),
742 pubkey_b64(11)
743 );
744 let err = parse_trust_verify_keys(&raw).expect_err("duplicate kid must be rejected");
745 let msg = format!("{err}");
746 assert!(
747 msg.contains("duplicate kid"),
748 "expected duplicate-kid error, got: {msg}"
749 );
750 }
751
752 #[test]
753 fn rejects_malformed_base64() {
754 let raw = r#"{ "ops-bad": "@@@not-base64@@@" }"#;
755 let err = parse_trust_verify_keys(raw).expect_err("malformed base64 must be rejected");
756 let msg = format!("{err}");
757 assert!(
758 msg.contains("not valid base64url"),
759 "expected base64-decode error, got: {msg}"
760 );
761 }
762
763 #[test]
764 fn rejects_wrong_length_pubkey() {
765 let too_short = URL_SAFE_NO_PAD.encode([0u8; 16]);
767 let raw = format!(r#"{{ "ops-short": "{too_short}" }}"#);
768 let err = parse_trust_verify_keys(&raw).expect_err("16-byte pubkey must be rejected");
769 let msg = format!("{err}");
770 assert!(
771 msg.contains("expected 32"),
772 "expected 32-byte length error, got: {msg}"
773 );
774 }
775
776 #[test]
777 fn empty_object_is_accepted() {
778 let raw = "{}";
779 let keys = parse_trust_verify_keys(raw).expect("empty object is the no-keys case");
780 assert!(keys.is_empty());
781 }
782
783 #[test]
784 fn missing_file_errors() {
785 let path = std::path::Path::new("/nonexistent/path/that/should/not/exist.json");
786 let err =
787 load_trust_verify_keys_file(path).expect_err("missing file must surface an error");
788 let msg = format!("{err}");
789 assert!(
790 msg.contains("cannot") && msg.contains("nonexistent"),
791 "expected file-open error, got: {msg}"
792 );
793 }
794
795 #[test]
796 fn rejects_non_utf8_input() {
797 let dir = tempfile::tempdir().expect("tmpdir");
798 let path = dir.path().join("trust-keys-non-utf8.json");
799 let mut f = std::fs::File::create(&path).expect("create");
800 f.write_all(&[0xFF, 0xFE, 0xFD, 0xFC]).expect("write");
802 drop(f);
803 let err = load_trust_verify_keys_file(&path).expect_err("non-utf8 must error");
804 let msg = format!("{err}");
805 assert!(
807 msg.contains("cannot read") || msg.contains("utf-8") || msg.contains("UTF-8"),
808 "expected non-utf8 read error, got: {msg}"
809 );
810 }
811
812 #[test]
813 fn rejects_top_level_non_object() {
814 let raw = r#"["not", "an", "object"]"#;
815 let err = parse_trust_verify_keys(raw).expect_err("top-level non-object must be rejected");
816 let msg = format!("{err}");
817 assert!(
818 msg.contains("must be a JSON object"),
819 "expected top-level-object error, got: {msg}"
820 );
821 }
822
823 #[test]
824 fn loads_valid_file_via_load_helper() {
825 let dir = tempfile::tempdir().expect("tmpdir");
829 let path = dir.path().join("trust-keys.json");
830 let raw = format!(
831 r#"{{ "kid-active-7": "{}", "kid-active-11": "{}" }}"#,
832 pubkey_b64(7),
833 pubkey_b64(11)
834 );
835 std::fs::write(&path, raw).expect("write keys");
836 let keys = load_trust_verify_keys_file(&path).expect("load via helper");
837 assert_eq!(keys.len(), 2);
838 assert_eq!(keys["kid-active-7"], anchor(7));
839 }
840
841 #[cfg(windows)]
846 #[test]
847 fn load_helper_rejects_reparse_point_at_final_component_windows() {
848 let dir = tempfile::tempdir().expect("tmpdir");
849 let real_path = dir.path().join("trust-keys-real.json");
850 let link_path = dir.path().join("trust-keys-link.json");
851 let raw = format!(r#"{{ "kid-only-1": "{}" }}"#, pubkey_b64(7));
852 std::fs::write(&real_path, raw).expect("write real keys file");
853
854 load_trust_verify_keys_file(&real_path).expect("real path loads");
856
857 if std::os::windows::fs::symlink_file(&real_path, &link_path).is_err() {
858 eprintln!(
859 "load_helper_rejects_reparse_point_at_final_component_windows: skipping — \
860 cannot create a symlink (no SeCreateSymbolicLinkPrivilege / Developer Mode)"
861 );
862 return;
863 }
864
865 let err = load_trust_verify_keys_file(&link_path)
866 .expect_err("a reparse point at the final component must be rejected");
867 assert!(
868 format!("{err}").contains("reparse point"),
869 "expected reparse-point rejection, got: {err}"
870 );
871 }
872
873 #[cfg(unix)]
879 #[test]
880 fn load_helper_rejects_symlink_at_final_component() {
881 let dir = tempfile::tempdir().expect("tmpdir");
882 let real_path = dir.path().join("trust-keys-real.json");
883 let symlink_path = dir.path().join("trust-keys-symlink.json");
884 let raw = format!(r#"{{ "kid-only-1": "{}" }}"#, pubkey_b64(7));
885 std::fs::write(&real_path, raw).expect("write real keys file");
886 std::os::unix::fs::symlink(&real_path, &symlink_path).expect("create symlink");
887
888 load_trust_verify_keys_file(&real_path).expect("real path loads");
890
891 let err = load_trust_verify_keys_file(&symlink_path)
893 .expect_err("symlink at final component must be rejected");
894 let msg = format!("{err}");
895 assert!(
896 msg.contains("cannot open"),
897 "expected open-side rejection, got: {msg}"
898 );
899 }
900
901 use super::{
904 canonical_event_signing_payload, sign_event_ed25519, sign_event_hmac_sha256,
905 sign_event_with, verify_signed_event_envelope, Signer, SoftwareSigner,
906 };
907 use crate::types::CloudEventV1;
908 use std::collections::HashMap;
909
910 fn sample_event() -> CloudEventV1 {
911 CloudEventV1 {
912 specversion: "1.0".into(),
913 id: "ev-001".into(),
914 source: "/cellos-supervisor".into(),
915 ty: "dev.cellos.events.cell.lifecycle.v1.started".into(),
916 datacontenttype: Some("application/json".into()),
917 data: Some(serde_json::json!({"cellId": "test-cell-1"})),
918 time: Some("2026-05-06T12:00:00Z".into()),
919 traceparent: None,
920 cex: None,
921 }
922 }
923
924 #[test]
925 fn ed25519_round_trip_verifies() {
926 let seed = seed_bytes(31);
927 let event = sample_event();
928 let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
929
930 assert_eq!(envelope.algorithm, "ed25519");
931 assert_eq!(envelope.signer_kid, "ops-event-2026-q2");
932
933 let mut keys = HashMap::new();
934 keys.insert("ops-event-2026-q2".to_string(), anchor(31));
935 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
936 let verified =
937 verify_signed_event_envelope(&envelope, &keys, &hmac_keys).expect("verify ok");
938 assert_eq!(verified.id, event.id);
939 }
940
941 #[test]
942 fn ed25519_tampered_event_fails_verify() {
943 let seed = seed_bytes(31);
944 let event = sample_event();
945 let mut envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
946 envelope.event.id = "ev-tampered".into();
947
948 let mut keys = HashMap::new();
949 keys.insert("ops-event-2026-q2".to_string(), anchor(31));
950 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
951 let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
952 .expect_err("tampered event must fail verify");
953 assert!(format!("{err}").contains("ed25519 verify failed"));
954 }
955
956 #[test]
957 fn ed25519_unknown_kid_fails_verify() {
958 let seed = seed_bytes(31);
959 let event = sample_event();
960 let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
961 let keys: HashMap<String, TrustAnchorPublicKey> = HashMap::new();
962 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
963 let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
964 .expect_err("unknown kid must fail");
965 assert!(format!("{err}").contains("unknown ed25519 signer kid"));
966 }
967
968 #[test]
969 fn software_signer_matches_sign_event_ed25519_and_verifies() {
970 let seed = seed_bytes(31);
975 let event = sample_event();
976 let kid = "ops-event-2026-q2";
977
978 let legacy = sign_event_ed25519(&event, kid, &seed).expect("legacy sign");
979 let signer = SoftwareSigner::from_seed(kid, seed).expect("build signer");
980 let via_seam = sign_event_with(&signer, &event).expect("seam sign");
981
982 assert_eq!(
983 serde_json::to_vec(&legacy).unwrap(),
984 serde_json::to_vec(&via_seam).unwrap(),
985 "SoftwareSigner must produce a byte-identical envelope to sign_event_ed25519"
986 );
987 assert_eq!(signer.kid(), kid);
988 assert_eq!(signer.verifying_key(), anchor(31));
989
990 let mut keys = HashMap::new();
991 keys.insert(kid.to_string(), signer.verifying_key());
992 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
993 let verified =
994 verify_signed_event_envelope(&via_seam, &keys, &hmac_keys).expect("verify ok");
995 assert_eq!(verified.id, event.id);
996 }
997
998 #[test]
999 fn revocation_list_revokes_kid_and_fails_closed_on_tamper() {
1000 use crate::crypto::{provider, TrustAnchorPublicKey};
1005 use crate::types::{
1006 RevocationListV1, RevokedSigner, SignedTrustKeysetEnvelope, TrustKeysetSignature,
1007 TRUST_REVOCATION_V1_PAYLOAD_TYPE,
1008 };
1009 use crate::{
1010 verify_and_parse_revocation_envelope, verify_signed_event_envelope_with_revocations,
1011 };
1012 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1013 use base64::Engine as _;
1014 use std::collections::HashSet;
1015 use std::fmt::Write as _;
1016 use std::time::SystemTime;
1017
1018 let org_seed = seed_bytes(42);
1020 let mut org_keys = HashMap::new();
1021 org_keys.insert(
1022 "org-root-2026".to_string(),
1023 TrustAnchorPublicKey::from_bytes_unchecked(
1024 provider().public_key_from_seed(&org_seed).unwrap(),
1025 ),
1026 );
1027
1028 let list = RevocationListV1 {
1030 schema_version: "1.0.0".into(),
1031 revocation_epoch: 1,
1032 revocations: vec![RevokedSigner {
1033 kid: "kid-bad".into(),
1034 revoked_at: "2026-06-23T00:00:00Z".into(),
1035 reason: "key_compromise".into(),
1036 }],
1037 };
1038 let payload_bytes = serde_json::to_vec(&list).unwrap();
1039 let sig = provider().sign_ed25519(&org_seed, &payload_bytes).unwrap();
1040 let mut digest_hex = String::from("sha256:");
1041 for b in provider().sha256(&payload_bytes) {
1042 write!(digest_hex, "{b:02x}").unwrap();
1043 }
1044 let make_env = |payload_b64: String, digest: String| SignedTrustKeysetEnvelope {
1045 schema_version: "1.0.0".into(),
1046 payload_type: TRUST_REVOCATION_V1_PAYLOAD_TYPE.into(),
1047 payload: payload_b64,
1048 signatures: vec![TrustKeysetSignature {
1049 signer_kid: "org-root-2026".into(),
1050 algorithm: "ed25519".into(),
1051 signature: URL_SAFE_NO_PAD.encode(sig),
1052 not_before: None,
1053 not_after: None,
1054 }],
1055 payload_digest: digest,
1056 produced_at: "2026-06-23T00:00:00Z".into(),
1057 replaces_envelope_digest: None,
1058 required_signer_count: None,
1059 };
1060 let envelope = make_env(URL_SAFE_NO_PAD.encode(&payload_bytes), digest_hex.clone());
1061
1062 let revoked: HashSet<String> =
1064 verify_and_parse_revocation_envelope(&envelope, &org_keys, SystemTime::now())
1065 .expect("revocation envelope must verify under org-root");
1066 assert!(revoked.contains("kid-bad"));
1067
1068 let event = sample_event();
1070 let bad_env = sign_event_ed25519(&event, "kid-bad", &seed_bytes(7)).unwrap();
1071 let mut event_keys = HashMap::new();
1072 event_keys.insert("kid-bad".to_string(), anchor(7));
1073 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
1074
1075 let empty: HashSet<String> = HashSet::new();
1077 verify_signed_event_envelope_with_revocations(&bad_env, &event_keys, &hmac_keys, &empty)
1078 .expect("unrevoked kid with a valid signature must verify");
1079
1080 let err = verify_signed_event_envelope_with_revocations(
1083 &bad_env,
1084 &event_keys,
1085 &hmac_keys,
1086 &revoked,
1087 )
1088 .expect_err("a revoked kid must be rejected even with a valid signature");
1089 assert!(
1090 format!("{err}").contains("signer_kid_revoked"),
1091 "expected signer_kid_revoked, got: {err}"
1092 );
1093
1094 let tampered = make_env(
1097 URL_SAFE_NO_PAD
1098 .encode(br#"{"schemaVersion":"1.0.0","revocationEpoch":2,"revocations":[]}"#),
1099 digest_hex,
1100 );
1101 let err = verify_and_parse_revocation_envelope(&tampered, &org_keys, SystemTime::now())
1102 .expect_err("a tampered revocation envelope must fail closed");
1103 assert!(
1104 format!("{err}").contains("digest mismatch") || format!("{err}").contains("verify"),
1105 "expected fail-closed digest/verify error, got: {err}"
1106 );
1107 }
1108
1109 #[test]
1110 fn hmac_sha256_round_trip_verifies() {
1111 let key = b"super-secret-shared-symmetric-key";
1112 let event = sample_event();
1113 let envelope = sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
1114 assert_eq!(envelope.algorithm, "hmac-sha256");
1115
1116 let verifying_keys: HashMap<String, _> = HashMap::new();
1117 let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
1118 hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
1119 let verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
1120 .expect("verify ok");
1121 assert_eq!(verified.id, event.id);
1122 }
1123
1124 #[test]
1125 fn hmac_sha256_tampered_event_fails_verify() {
1126 let key = b"super-secret-shared-symmetric-key";
1127 let event = sample_event();
1128 let mut envelope =
1129 sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
1130 envelope.event.ty = "dev.cellos.events.cell.lifecycle.v1.destroyed".into();
1131
1132 let verifying_keys: HashMap<String, _> = HashMap::new();
1133 let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
1134 hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
1135 let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
1136 .expect_err("tampered event must fail");
1137 assert!(format!("{err}").contains("hmac-sha256 verify failed"));
1138 }
1139
1140 #[test]
1141 fn unknown_algorithm_rejected() {
1142 let seed = seed_bytes(31);
1143 let event = sample_event();
1144 let mut envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &seed).expect("sign ok");
1145 envelope.algorithm = "rsa-pss-sha512".into();
1146
1147 let verifying_keys: HashMap<String, _> = HashMap::new();
1148 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
1149 let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
1150 .expect_err("unknown algorithm must be rejected");
1151 assert!(format!("{err}").contains("unknown algorithm"));
1152 }
1153
1154 #[test]
1155 fn canonical_payload_is_deterministic() {
1156 let event = sample_event();
1157 let a = canonical_event_signing_payload(&event).expect("a");
1158 let b = canonical_event_signing_payload(&event).expect("b");
1159 assert_eq!(a, b, "canonical signing payload must be byte-identical");
1160 }
1161}