1use std::collections::HashMap;
41use std::path::Path;
42
43use base64::engine::general_purpose::URL_SAFE_NO_PAD;
44use base64::Engine as _;
45use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48use sha2::{Digest, Sha256};
49
50use crate::error::CellosError;
51use crate::types::CloudEventV1;
52
53pub fn parse_trust_verify_keys(raw: &str) -> Result<HashMap<String, VerifyingKey>, CellosError> {
76 let value: Value = serde_json::from_str(raw).map_err(|e| {
77 CellosError::InvalidSpec(format!("trust verify keys: JSON parse error: {e}"))
78 })?;
79
80 let object = value.as_object().ok_or_else(|| {
81 CellosError::InvalidSpec(
82 "trust verify keys: top-level value must be a JSON object mapping kid -> base64url-pubkey".into(),
83 )
84 })?;
85
86 detect_duplicate_keys(raw)?;
91
92 let mut keys: HashMap<String, VerifyingKey> = HashMap::with_capacity(object.len());
93 for (kid, value) in object {
94 let pubkey_b64 = value.as_str().ok_or_else(|| {
95 CellosError::InvalidSpec(format!(
96 "trust verify keys: value for kid {kid:?} must be a base64url string, got {value}"
97 ))
98 })?;
99
100 let trimmed = pubkey_b64.trim_end_matches('=');
102 let bytes = URL_SAFE_NO_PAD.decode(trimmed).map_err(|e| {
103 CellosError::InvalidSpec(format!(
104 "trust verify keys: kid {kid:?} value is not valid base64url: {e}"
105 ))
106 })?;
107
108 let array: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
109 CellosError::InvalidSpec(format!(
110 "trust verify keys: kid {kid:?} decoded to {} bytes, expected 32",
111 bytes.len()
112 ))
113 })?;
114
115 let verifying_key = VerifyingKey::from_bytes(&array).map_err(|e| {
116 CellosError::InvalidSpec(format!(
117 "trust verify keys: kid {kid:?} is not a valid Ed25519 verifying key: {e}"
118 ))
119 })?;
120
121 keys.insert(kid.clone(), verifying_key);
122 }
123
124 Ok(keys)
125}
126
127pub fn load_trust_verify_keys_file(
140 path: &Path,
141) -> Result<HashMap<String, VerifyingKey>, CellosError> {
142 #[cfg(unix)]
143 let raw = {
144 use std::io::Read;
145 use std::os::unix::fs::OpenOptionsExt;
146 let mut opts = std::fs::OpenOptions::new();
147 opts.read(true);
148 #[cfg(target_os = "linux")]
158 const O_NOFOLLOW: i32 = 0x20000;
159 #[cfg(any(
160 target_os = "macos",
161 target_os = "ios",
162 target_os = "freebsd",
163 target_os = "netbsd",
164 target_os = "openbsd",
165 target_os = "dragonfly",
166 ))]
167 const O_NOFOLLOW: i32 = 0x100;
168 #[cfg(not(any(
171 target_os = "linux",
172 target_os = "macos",
173 target_os = "ios",
174 target_os = "freebsd",
175 target_os = "netbsd",
176 target_os = "openbsd",
177 target_os = "dragonfly",
178 )))]
179 compile_error!(
180 "cellos-core::trust_keys: O_NOFOLLOW value not yet defined for this Unix target — \
181 add the platform-specific value (see <fcntl.h>) before building."
182 );
183 opts.custom_flags(O_NOFOLLOW);
184 let mut file = opts.open(path).map_err(|e| {
185 CellosError::InvalidSpec(format!(
186 "trust verify keys: cannot open {}: {e}",
187 path.display()
188 ))
189 })?;
190 let mut buf = String::new();
191 file.read_to_string(&mut buf).map_err(|e| {
192 CellosError::InvalidSpec(format!(
193 "trust verify keys: cannot read {}: {e}",
194 path.display()
195 ))
196 })?;
197 buf
198 };
199 #[cfg(not(unix))]
200 let raw = std::fs::read_to_string(path).map_err(|e| {
201 CellosError::InvalidSpec(format!(
202 "trust verify keys: cannot read {}: {e}",
203 path.display()
204 ))
205 })?;
206
207 parse_trust_verify_keys(&raw)
208}
209
210fn detect_duplicate_keys(raw: &str) -> Result<(), CellosError> {
226 use std::collections::HashSet;
227
228 let bytes = raw.as_bytes();
229 let mut seen: HashSet<String> = HashSet::new();
230 let mut idx = 0;
231 let mut depth: i32 = 0;
232 let mut in_string = false;
233 let mut after_colon_in_outer = false;
234 let mut current_key: Option<String> = None;
235 let mut escape = false;
236 let mut started = false;
237
238 while idx < bytes.len() {
239 let b = bytes[idx];
240 if in_string {
241 if escape {
242 escape = false;
243 if let Some(k) = current_key.as_mut() {
244 k.push(b as char);
245 }
246 idx += 1;
247 continue;
248 }
249 match b {
250 b'\\' => {
251 escape = true;
252 if let Some(k) = current_key.as_mut() {
253 k.push(b as char);
254 }
255 }
256 b'"' => {
257 in_string = false;
258 if depth == 1 && !after_colon_in_outer {
259 if let Some(key) = current_key.take() {
260 if !seen.insert(key.clone()) {
261 return Err(CellosError::InvalidSpec(format!(
262 "trust verify keys: duplicate kid {key:?} in keys file"
263 )));
264 }
265 }
266 } else {
267 let _ = current_key.take();
269 }
270 }
271 _ => {
272 if let Some(k) = current_key.as_mut() {
273 k.push(b as char);
274 }
275 }
276 }
277 idx += 1;
278 continue;
279 }
280
281 match b {
282 b'"' => {
283 in_string = true;
284 if depth == 1 && !after_colon_in_outer {
287 current_key = Some(String::new());
288 } else {
289 current_key = Some(String::new()); }
293 }
294 b'{' => {
295 depth += 1;
296 started = true;
297 }
298 b'}' => {
299 depth -= 1;
300 after_colon_in_outer = false;
301 if depth == 0 {
302 return Ok(());
303 }
304 }
305 b'[' => {
306 depth += 1;
307 }
308 b']' => {
309 depth -= 1;
310 }
311 b':' => {
312 if depth == 1 {
313 after_colon_in_outer = true;
314 }
315 }
316 b',' => {
317 if depth == 1 {
318 after_colon_in_outer = false;
319 }
320 }
321 _ => {}
322 }
323 idx += 1;
324 }
325
326 let _ = started;
330 Ok(())
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase")]
356pub struct SignedEventEnvelopeV1 {
357 pub event: CloudEventV1,
358 pub signer_kid: String,
359 pub algorithm: String,
360 pub signature: String,
361 #[serde(skip_serializing_if = "Option::is_none")]
362 pub not_before: Option<String>,
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub not_after: Option<String>,
365}
366
367pub fn canonical_event_signing_payload(event: &CloudEventV1) -> Result<Vec<u8>, CellosError> {
369 serde_json::to_vec(event).map_err(|e| {
370 CellosError::InvalidSpec(format!("canonical_event_signing_payload: serialize: {e}"))
371 })
372}
373
374pub fn sign_event_ed25519(
376 event: &CloudEventV1,
377 signer_kid: &str,
378 signing_key: &SigningKey,
379) -> Result<SignedEventEnvelopeV1, CellosError> {
380 use ed25519_dalek::Signer;
381 let payload = canonical_event_signing_payload(event)?;
382 let signature = signing_key.sign(&payload);
383 Ok(SignedEventEnvelopeV1 {
384 event: event.clone(),
385 signer_kid: signer_kid.to_string(),
386 algorithm: "ed25519".to_string(),
387 signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
388 not_before: None,
389 not_after: None,
390 })
391}
392
393pub fn sign_event_hmac_sha256(
395 event: &CloudEventV1,
396 signer_kid: &str,
397 key_bytes: &[u8],
398) -> Result<SignedEventEnvelopeV1, CellosError> {
399 let payload = canonical_event_signing_payload(event)?;
400 let mac = hmac_sha256(key_bytes, &payload);
401 Ok(SignedEventEnvelopeV1 {
402 event: event.clone(),
403 signer_kid: signer_kid.to_string(),
404 algorithm: "hmac-sha256".to_string(),
405 signature: URL_SAFE_NO_PAD.encode(mac),
406 not_before: None,
407 not_after: None,
408 })
409}
410
411pub fn verify_signed_event_envelope<'a>(
413 envelope: &'a SignedEventEnvelopeV1,
414 verifying_keys: &HashMap<String, VerifyingKey>,
415 hmac_keys: &HashMap<String, Vec<u8>>,
416) -> Result<&'a CloudEventV1, CellosError> {
417 let payload = canonical_event_signing_payload(&envelope.event)?;
418 let sig_b64 = envelope.signature.trim_end_matches('=');
419 let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|e| {
420 CellosError::InvalidSpec(format!(
421 "signed event envelope: signature is not valid base64url: {e}"
422 ))
423 })?;
424
425 match envelope.algorithm.as_str() {
426 "ed25519" => {
427 let verifying_key = verifying_keys.get(&envelope.signer_kid).ok_or_else(|| {
428 CellosError::InvalidSpec(format!(
429 "signed event envelope: unknown ed25519 signer kid {:?}",
430 envelope.signer_kid
431 ))
432 })?;
433 let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
434 CellosError::InvalidSpec(format!(
435 "signed event envelope: ed25519 signature must be 64 bytes, got {}",
436 sig_bytes.len()
437 ))
438 })?;
439 let signature = Signature::from_bytes(&sig_array);
440 verifying_key
441 .verify_strict(&payload, &signature)
442 .map_err(|e| {
443 CellosError::InvalidSpec(format!(
444 "signed event envelope: ed25519 verify failed: {e}"
445 ))
446 })?;
447 Ok(&envelope.event)
448 }
449 "hmac-sha256" => {
450 let key = hmac_keys.get(&envelope.signer_kid).ok_or_else(|| {
451 CellosError::InvalidSpec(format!(
452 "signed event envelope: unknown hmac-sha256 signer kid {:?}",
453 envelope.signer_kid
454 ))
455 })?;
456 if sig_bytes.len() != 32 {
457 return Err(CellosError::InvalidSpec(format!(
458 "signed event envelope: hmac-sha256 mac must be 32 bytes, got {}",
459 sig_bytes.len()
460 )));
461 }
462 let expected = hmac_sha256(key, &payload);
463 if !constant_time_eq(&expected, &sig_bytes) {
464 return Err(CellosError::InvalidSpec(
465 "signed event envelope: hmac-sha256 verify failed".into(),
466 ));
467 }
468 Ok(&envelope.event)
469 }
470 other => Err(CellosError::InvalidSpec(format!(
471 "signed event envelope: unknown algorithm {other:?} (expected ed25519 or hmac-sha256)"
472 ))),
473 }
474}
475
476fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
478 const BLOCK: usize = 64;
479 let mut block_key = [0u8; BLOCK];
480 if key.len() > BLOCK {
481 let mut hasher = Sha256::new();
482 hasher.update(key);
483 let digest = hasher.finalize();
484 block_key[..32].copy_from_slice(&digest);
485 } else {
486 block_key[..key.len()].copy_from_slice(key);
487 }
488
489 let mut ipad = [0u8; BLOCK];
490 let mut opad = [0u8; BLOCK];
491 for i in 0..BLOCK {
492 ipad[i] = block_key[i] ^ 0x36;
493 opad[i] = block_key[i] ^ 0x5c;
494 }
495
496 let mut inner = Sha256::new();
497 inner.update(ipad);
498 inner.update(message);
499 let inner_digest = inner.finalize();
500
501 let mut outer = Sha256::new();
502 outer.update(opad);
503 outer.update(inner_digest);
504 let mac = outer.finalize();
505
506 let mut out = [0u8; 32];
507 out.copy_from_slice(&mac);
508 out
509}
510
511fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
513 if a.len() != b.len() {
514 return false;
515 }
516 let mut diff: u8 = 0;
517 for (x, y) in a.iter().zip(b.iter()) {
518 diff |= x ^ y;
519 }
520 diff == 0
521}
522
523#[cfg(test)]
524mod tests {
525 use super::{load_trust_verify_keys_file, parse_trust_verify_keys};
526 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
527 use base64::Engine as _;
528 use ed25519_dalek::SigningKey;
529 use std::io::Write;
530
531 fn signing_key(seed: u8) -> SigningKey {
532 SigningKey::from_bytes(&[seed; 32])
533 }
534
535 fn pubkey_b64(seed: u8) -> String {
536 let signer = signing_key(seed);
537 URL_SAFE_NO_PAD.encode(signer.verifying_key().to_bytes())
538 }
539
540 #[test]
541 fn parses_well_formed_two_key_map() {
542 let raw = format!(
543 r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q3": "{}" }}"#,
544 pubkey_b64(7),
545 pubkey_b64(11)
546 );
547 let keys = parse_trust_verify_keys(&raw).expect("well-formed map must parse");
548 assert_eq!(keys.len(), 2);
549 assert!(keys.contains_key("ops-envelope-2026-q2"));
550 assert!(keys.contains_key("ops-envelope-2026-q3"));
551 assert_eq!(
552 keys["ops-envelope-2026-q2"],
553 signing_key(7).verifying_key(),
554 "kid q2 must round-trip to its source verifying key"
555 );
556 }
557
558 #[test]
559 fn rejects_duplicate_kid() {
560 let raw = format!(
561 r#"{{ "ops-envelope-2026-q2": "{}", "ops-envelope-2026-q2": "{}" }}"#,
562 pubkey_b64(7),
563 pubkey_b64(11)
564 );
565 let err = parse_trust_verify_keys(&raw).expect_err("duplicate kid must be rejected");
566 let msg = format!("{err}");
567 assert!(
568 msg.contains("duplicate kid"),
569 "expected duplicate-kid error, got: {msg}"
570 );
571 }
572
573 #[test]
574 fn rejects_malformed_base64() {
575 let raw = r#"{ "ops-bad": "@@@not-base64@@@" }"#;
576 let err = parse_trust_verify_keys(raw).expect_err("malformed base64 must be rejected");
577 let msg = format!("{err}");
578 assert!(
579 msg.contains("not valid base64url"),
580 "expected base64-decode error, got: {msg}"
581 );
582 }
583
584 #[test]
585 fn rejects_wrong_length_pubkey() {
586 let too_short = URL_SAFE_NO_PAD.encode([0u8; 16]);
588 let raw = format!(r#"{{ "ops-short": "{too_short}" }}"#);
589 let err = parse_trust_verify_keys(&raw).expect_err("16-byte pubkey must be rejected");
590 let msg = format!("{err}");
591 assert!(
592 msg.contains("expected 32"),
593 "expected 32-byte length error, got: {msg}"
594 );
595 }
596
597 #[test]
598 fn empty_object_is_accepted() {
599 let raw = "{}";
600 let keys = parse_trust_verify_keys(raw).expect("empty object is the no-keys case");
601 assert!(keys.is_empty());
602 }
603
604 #[test]
605 fn missing_file_errors() {
606 let path = std::path::Path::new("/nonexistent/path/that/should/not/exist.json");
607 let err =
608 load_trust_verify_keys_file(path).expect_err("missing file must surface an error");
609 let msg = format!("{err}");
610 assert!(
611 msg.contains("cannot") && msg.contains("nonexistent"),
612 "expected file-open error, got: {msg}"
613 );
614 }
615
616 #[test]
617 fn rejects_non_utf8_input() {
618 let dir = tempfile::tempdir().expect("tmpdir");
619 let path = dir.path().join("trust-keys-non-utf8.json");
620 let mut f = std::fs::File::create(&path).expect("create");
621 f.write_all(&[0xFF, 0xFE, 0xFD, 0xFC]).expect("write");
623 drop(f);
624 let err = load_trust_verify_keys_file(&path).expect_err("non-utf8 must error");
625 let msg = format!("{err}");
626 assert!(
628 msg.contains("cannot read") || msg.contains("utf-8") || msg.contains("UTF-8"),
629 "expected non-utf8 read error, got: {msg}"
630 );
631 }
632
633 #[test]
634 fn rejects_top_level_non_object() {
635 let raw = r#"["not", "an", "object"]"#;
636 let err = parse_trust_verify_keys(raw).expect_err("top-level non-object must be rejected");
637 let msg = format!("{err}");
638 assert!(
639 msg.contains("must be a JSON object"),
640 "expected top-level-object error, got: {msg}"
641 );
642 }
643
644 #[test]
645 fn loads_valid_file_via_load_helper() {
646 let dir = tempfile::tempdir().expect("tmpdir");
650 let path = dir.path().join("trust-keys.json");
651 let raw = format!(
652 r#"{{ "kid-active-7": "{}", "kid-active-11": "{}" }}"#,
653 pubkey_b64(7),
654 pubkey_b64(11)
655 );
656 std::fs::write(&path, raw).expect("write keys");
657 let keys = load_trust_verify_keys_file(&path).expect("load via helper");
658 assert_eq!(keys.len(), 2);
659 assert_eq!(keys["kid-active-7"], signing_key(7).verifying_key());
660 }
661
662 #[cfg(unix)]
668 #[test]
669 fn load_helper_rejects_symlink_at_final_component() {
670 let dir = tempfile::tempdir().expect("tmpdir");
671 let real_path = dir.path().join("trust-keys-real.json");
672 let symlink_path = dir.path().join("trust-keys-symlink.json");
673 let raw = format!(r#"{{ "kid-only-1": "{}" }}"#, pubkey_b64(7));
674 std::fs::write(&real_path, raw).expect("write real keys file");
675 std::os::unix::fs::symlink(&real_path, &symlink_path).expect("create symlink");
676
677 load_trust_verify_keys_file(&real_path).expect("real path loads");
679
680 let err = load_trust_verify_keys_file(&symlink_path)
682 .expect_err("symlink at final component must be rejected");
683 let msg = format!("{err}");
684 assert!(
685 msg.contains("cannot open"),
686 "expected open-side rejection, got: {msg}"
687 );
688 }
689
690 use super::{
693 canonical_event_signing_payload, sign_event_ed25519, sign_event_hmac_sha256,
694 verify_signed_event_envelope,
695 };
696 use crate::types::CloudEventV1;
697 use std::collections::HashMap;
698
699 fn sample_event() -> CloudEventV1 {
700 CloudEventV1 {
701 specversion: "1.0".into(),
702 id: "ev-001".into(),
703 source: "/cellos-supervisor".into(),
704 ty: "dev.cellos.events.cell.lifecycle.v1.started".into(),
705 datacontenttype: Some("application/json".into()),
706 data: Some(serde_json::json!({"cellId": "test-cell-1"})),
707 time: Some("2026-05-06T12:00:00Z".into()),
708 traceparent: None,
709 }
710 }
711
712 #[test]
713 fn ed25519_round_trip_verifies() {
714 let signer = signing_key(31);
715 let event = sample_event();
716 let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
717
718 assert_eq!(envelope.algorithm, "ed25519");
719 assert_eq!(envelope.signer_kid, "ops-event-2026-q2");
720
721 let mut keys = HashMap::new();
722 keys.insert("ops-event-2026-q2".to_string(), signer.verifying_key());
723 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
724 let verified =
725 verify_signed_event_envelope(&envelope, &keys, &hmac_keys).expect("verify ok");
726 assert_eq!(verified.id, event.id);
727 }
728
729 #[test]
730 fn ed25519_tampered_event_fails_verify() {
731 let signer = signing_key(31);
732 let event = sample_event();
733 let mut envelope =
734 sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
735 envelope.event.id = "ev-tampered".into();
736
737 let mut keys = HashMap::new();
738 keys.insert("ops-event-2026-q2".to_string(), signer.verifying_key());
739 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
740 let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
741 .expect_err("tampered event must fail verify");
742 assert!(format!("{err}").contains("ed25519 verify failed"));
743 }
744
745 #[test]
746 fn ed25519_unknown_kid_fails_verify() {
747 let signer = signing_key(31);
748 let event = sample_event();
749 let envelope = sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
750 let keys: HashMap<String, _> = HashMap::new();
751 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
752 let err = verify_signed_event_envelope(&envelope, &keys, &hmac_keys)
753 .expect_err("unknown kid must fail");
754 assert!(format!("{err}").contains("unknown ed25519 signer kid"));
755 }
756
757 #[test]
758 fn hmac_sha256_round_trip_verifies() {
759 let key = b"super-secret-shared-symmetric-key";
760 let event = sample_event();
761 let envelope = sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
762 assert_eq!(envelope.algorithm, "hmac-sha256");
763
764 let verifying_keys: HashMap<String, _> = HashMap::new();
765 let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
766 hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
767 let verified = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
768 .expect("verify ok");
769 assert_eq!(verified.id, event.id);
770 }
771
772 #[test]
773 fn hmac_sha256_tampered_event_fails_verify() {
774 let key = b"super-secret-shared-symmetric-key";
775 let event = sample_event();
776 let mut envelope =
777 sign_event_hmac_sha256(&event, "ops-hmac-2026-q2", key).expect("sign ok");
778 envelope.event.ty = "dev.cellos.events.cell.lifecycle.v1.destroyed".into();
779
780 let verifying_keys: HashMap<String, _> = HashMap::new();
781 let mut hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
782 hmac_keys.insert("ops-hmac-2026-q2".to_string(), key.to_vec());
783 let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
784 .expect_err("tampered event must fail");
785 assert!(format!("{err}").contains("hmac-sha256 verify failed"));
786 }
787
788 #[test]
789 fn unknown_algorithm_rejected() {
790 let signer = signing_key(31);
791 let event = sample_event();
792 let mut envelope =
793 sign_event_ed25519(&event, "ops-event-2026-q2", &signer).expect("sign ok");
794 envelope.algorithm = "rsa-pss-sha512".into();
795
796 let verifying_keys: HashMap<String, _> = HashMap::new();
797 let hmac_keys: HashMap<String, Vec<u8>> = HashMap::new();
798 let err = verify_signed_event_envelope(&envelope, &verifying_keys, &hmac_keys)
799 .expect_err("unknown algorithm must be rejected");
800 assert!(format!("{err}").contains("unknown algorithm"));
801 }
802
803 #[test]
804 fn canonical_payload_is_deterministic() {
805 let event = sample_event();
806 let a = canonical_event_signing_payload(&event).expect("a");
807 let b = canonical_event_signing_payload(&event).expect("b");
808 assert_eq!(a, b, "canonical signing payload must be byte-identical");
809 }
810}