1use anyhow::{anyhow, bail};
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4
5pub const CMN_SCHEMA: &str = "https://cmn.dev/schemas/v1/cmn.json";
6pub const KEY_ROTATION_PURPOSE: &str = "cmn-key-rotation-v1";
7
8#[derive(Serialize, Deserialize, Debug, Clone)]
13pub struct CmnEntry {
14 #[serde(rename = "$schema")]
15 pub schema: String,
16 pub capsules: Vec<CmnCapsuleEntry>,
17 pub capsule_signature: String,
18}
19
20#[derive(Serialize, Deserialize, Debug, Clone)]
22pub struct CmnCapsuleEntry {
23 pub uri: String,
24 pub serial: u64,
25 pub key: String,
26 pub history: Vec<KeyHistoryEntry>,
27 pub endpoints: Vec<CmnEndpoint>,
28}
29
30#[derive(Serialize, Deserialize, Debug, Clone)]
32pub struct KeyHistoryEntry {
33 pub key: String,
34 #[serde(default)]
35 pub status: KeyHistoryStatus,
36 pub retired_at_epoch_ms: u64,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub replaced_by: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub effective_serial: Option<u64>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub rotation_signature: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub revoked_at_epoch_ms: Option<u64>,
45}
46
47#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
49#[serde(rename_all = "snake_case")]
50pub enum KeyHistoryStatus {
51 #[default]
53 Retired,
54 Revoked,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum KeyConfirmation {
61 Current,
62 Retired { retired_at_epoch_ms: u64 },
63}
64
65impl KeyConfirmation {
66 pub fn retired_at_epoch_ms(self) -> Option<u64> {
67 match self {
68 Self::Current => None,
69 Self::Retired {
70 retired_at_epoch_ms,
71 } => Some(retired_at_epoch_ms),
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct KeyRotationStatement {
79 pub purpose: String,
80 pub domain: String,
81 pub from: String,
82 pub to: String,
83 pub effective_serial: u64,
84 pub retired_at_epoch_ms: u64,
85}
86
87#[derive(Serialize, Deserialize, Debug, Clone)]
89pub struct CmnEndpoint {
90 #[serde(rename = "type")]
91 pub kind: String,
92 pub url: String,
93 #[serde(default, skip_serializing_if = "String::is_empty")]
95 pub hash: String,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub hashes: Vec<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub format: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub delta_url: Option<String>,
103}
104
105pub fn build_key_rotation_statement(
106 domain: &str,
107 from: &str,
108 to: &str,
109 effective_serial: u64,
110 retired_at_epoch_ms: u64,
111) -> KeyRotationStatement {
112 KeyRotationStatement {
113 purpose: KEY_ROTATION_PURPOSE.to_string(),
114 domain: domain.to_string(),
115 from: from.to_string(),
116 to: to.to_string(),
117 effective_serial,
118 retired_at_epoch_ms,
119 }
120}
121
122pub fn verify_key_rotation_statement(
123 domain: &str,
124 from: &str,
125 to: &str,
126 effective_serial: u64,
127 retired_at_epoch_ms: u64,
128 rotation_signature: &str,
129) -> anyhow::Result<()> {
130 let statement =
131 build_key_rotation_statement(domain, from, to, effective_serial, retired_at_epoch_ms);
132 crate::verify_json_signature(&statement, rotation_signature, from)
133}
134
135impl KeyHistoryEntry {
136 pub fn verify_rotation(&self, domain: &str, current_serial: u64) -> anyhow::Result<()> {
137 if self.status != KeyHistoryStatus::Retired {
138 bail!("Only retired history entries can authorize key rotation");
139 }
140 let effective_serial = self.effective_serial.unwrap_or(current_serial);
141 let to = self
142 .replaced_by
143 .as_deref()
144 .ok_or_else(|| anyhow!("Missing replaced_by for retired key history entry"))?;
145 let rotation_signature = self
146 .rotation_signature
147 .as_deref()
148 .ok_or_else(|| anyhow!("Missing rotation_signature for retired key history entry"))?;
149 verify_key_rotation_statement(
150 domain,
151 &self.key,
152 to,
153 effective_serial,
154 self.retired_at_epoch_ms,
155 rotation_signature,
156 )
157 }
158}
159
160impl CmnCapsuleEntry {
161 fn rotation_domain(&self) -> anyhow::Result<&str> {
162 self.uri
163 .strip_prefix("cmn://")
164 .filter(|domain| !domain.is_empty())
165 .ok_or_else(|| anyhow!("Invalid cmn.json capsule uri: {}", self.uri))
166 }
167
168 pub fn confirms_key(&self, key: &str) -> bool {
169 self.key == key
170 || self
171 .history
172 .iter()
173 .any(|entry| self.confirms_history_key(entry, key))
174 }
175
176 fn confirms_history_key(&self, entry: &KeyHistoryEntry, key: &str) -> bool {
177 if entry.key != key || entry.status != KeyHistoryStatus::Retired {
178 return false;
179 }
180 let Ok(domain) = self.rotation_domain() else {
181 return false;
182 };
183 entry.verify_rotation(domain, self.serial).is_ok()
184 }
185
186 pub fn key_confirmation_at(
187 &self,
188 key: &str,
189 signed_at_epoch_ms: u64,
190 ) -> Option<KeyConfirmation> {
191 if self.key == key {
192 return Some(KeyConfirmation::Current);
193 }
194
195 let domain = self.rotation_domain().ok()?;
196 self.history.iter().find_map(|entry| {
197 if entry.key != key {
198 return None;
199 }
200 match entry.status {
201 KeyHistoryStatus::Retired
202 if signed_at_epoch_ms <= entry.retired_at_epoch_ms
203 && entry.verify_rotation(domain, self.serial).is_ok() =>
204 {
205 Some(KeyConfirmation::Retired {
206 retired_at_epoch_ms: entry.retired_at_epoch_ms,
207 })
208 }
209 KeyHistoryStatus::Retired | KeyHistoryStatus::Revoked => None,
210 }
211 })
212 }
213
214 pub fn confirms_key_at(&self, key: &str, signed_at_epoch_ms: u64) -> bool {
215 self.key_confirmation_at(key, signed_at_epoch_ms).is_some()
216 }
217
218 pub fn find_endpoint(&self, kind: &str) -> Option<&CmnEndpoint> {
219 self.endpoints.iter().find(|endpoint| endpoint.kind == kind)
220 }
221
222 pub fn find_endpoints(&self, kind: &str) -> Vec<&CmnEndpoint> {
223 self.endpoints
224 .iter()
225 .filter(|endpoint| endpoint.kind == kind)
226 .collect()
227 }
228
229 pub fn find_archive_endpoint(&self, format: Option<&str>) -> Option<&CmnEndpoint> {
230 match format {
231 Some(expected) => self.endpoints.iter().find(|endpoint| {
232 endpoint.kind == "archive" && endpoint.format.as_deref() == Some(expected)
233 }),
234 None => self.find_endpoint("archive"),
235 }
236 }
237
238 pub fn mycelium_hash(&self) -> Option<&str> {
240 self.find_endpoint("mycelium")
241 .map(|endpoint| endpoint.hash.as_str())
242 .filter(|h| !h.is_empty())
243 }
244
245 pub fn mycelium_hashes(&self) -> &[String] {
247 self.find_endpoint("mycelium")
248 .map(|endpoint| endpoint.hashes.as_slice())
249 .unwrap_or(&[])
250 }
251
252 fn require_endpoint(&self, kind: &str) -> anyhow::Result<&CmnEndpoint> {
253 self.find_endpoint(kind)
254 .ok_or_else(|| anyhow!("No '{}' endpoint configured", kind))
255 }
256
257 fn require_archive_endpoint(&self, format: Option<&str>) -> anyhow::Result<&CmnEndpoint> {
258 match format {
259 Some(expected) => self
260 .find_archive_endpoint(Some(expected))
261 .ok_or_else(|| anyhow!("No archive endpoint configured for format '{}'", expected)),
262 None => self
263 .find_archive_endpoint(None)
264 .ok_or_else(|| anyhow!("No 'archive' endpoint configured")),
265 }
266 }
267
268 pub fn mycelium_url(&self, hash: &str) -> anyhow::Result<String> {
269 self.require_endpoint("mycelium")?.resolve_url(hash)
270 }
271
272 pub fn spore_url(&self, hash: &str) -> anyhow::Result<String> {
273 self.require_endpoint("spore")?.resolve_url(hash)
274 }
275
276 pub fn archive_url(&self, hash: &str) -> anyhow::Result<String> {
277 self.require_archive_endpoint(None)?.resolve_url(hash)
278 }
279
280 pub fn archive_url_for_format(&self, hash: &str, format: &str) -> anyhow::Result<String> {
281 self.require_archive_endpoint(Some(format))?
282 .resolve_url(hash)
283 }
284
285 pub fn archive_delta_url(
286 &self,
287 hash: &str,
288 old_hash: &str,
289 format: Option<&str>,
290 ) -> anyhow::Result<Option<String>> {
291 self.require_archive_endpoint(format)?
292 .resolve_delta_url(hash, old_hash)
293 }
294
295 pub fn taste_url(&self, hash: &str) -> anyhow::Result<String> {
296 self.require_endpoint("taste")?.resolve_url(hash)
297 }
298
299 pub fn verify_rotation_chain_from(&self, pinned_key: &str) -> anyhow::Result<()> {
300 if pinned_key == self.key {
301 return Ok(());
302 }
303
304 let domain = self.rotation_domain()?;
305 let mut current = pinned_key.to_string();
306 let mut seen = HashSet::new();
307
308 for _ in 0..self.history.len() {
309 if !seen.insert(current.clone()) {
310 bail!("Key rotation history contains a cycle at {}", current);
311 }
312 let entry = self
313 .history
314 .iter()
315 .find(|entry| entry.key == current && entry.status == KeyHistoryStatus::Retired)
316 .ok_or_else(|| anyhow!("No retired history entry for key {}", current))?;
317 entry.verify_rotation(domain, self.serial)?;
318 let next = entry
319 .replaced_by
320 .as_deref()
321 .ok_or_else(|| anyhow!("Missing replaced_by for key {}", current))?;
322 if next == self.key {
323 return Ok(());
324 }
325 current = next.to_string();
326 }
327
328 bail!(
329 "No verified key rotation chain from pinned key {} to current key {}",
330 pinned_key,
331 self.key
332 )
333 }
334}
335
336impl CmnEntry {
337 pub fn new(capsules: Vec<CmnCapsuleEntry>) -> Self {
338 Self {
339 schema: CMN_SCHEMA.to_string(),
340 capsules,
341 capsule_signature: String::new(),
342 }
343 }
344
345 pub fn primary_capsule(&self) -> anyhow::Result<&CmnCapsuleEntry> {
346 self.capsules
347 .first()
348 .ok_or_else(|| anyhow!("Invalid cmn.json: capsules must contain at least one entry"))
349 }
350
351 pub fn uri(&self) -> anyhow::Result<&str> {
352 self.primary_capsule().map(|capsule| capsule.uri.as_str())
353 }
354
355 pub fn primary_key(&self) -> anyhow::Result<&str> {
356 self.primary_capsule().map(|capsule| capsule.key.as_str())
357 }
358
359 pub fn primary_confirms_key(&self, key: &str) -> anyhow::Result<bool> {
360 self.primary_capsule()
361 .map(|capsule| capsule.confirms_key(key))
362 }
363
364 pub fn primary_key_confirmation_at(
365 &self,
366 key: &str,
367 signed_at_epoch_ms: u64,
368 ) -> anyhow::Result<Option<KeyConfirmation>> {
369 self.primary_capsule()
370 .map(|capsule| capsule.key_confirmation_at(key, signed_at_epoch_ms))
371 }
372
373 pub fn primary_confirms_key_at(
374 &self,
375 key: &str,
376 signed_at_epoch_ms: u64,
377 ) -> anyhow::Result<bool> {
378 self.primary_key_confirmation_at(key, signed_at_epoch_ms)
379 .map(|confirmation| confirmation.is_some())
380 }
381
382 pub fn verify_signature(&self, host_key: &str) -> anyhow::Result<()> {
383 crate::verify_json_signature(&self.capsules, &self.capsule_signature, host_key)
384 }
385
386 pub fn capsules_digest(&self) -> anyhow::Result<String> {
387 let canonical = serde_jcs::to_string(&self.capsules)
388 .map_err(|e| anyhow!("JCS serialization failed: {}", e))?;
389 Ok(crate::compute_blake3_hash(canonical.as_bytes()))
390 }
391}
392
393impl CmnEndpoint {
394 pub fn resolve_url(&self, hash: &str) -> anyhow::Result<String> {
395 let url = self.url.replace("{hash}", hash);
396 crate::uri::normalize_and_validate_url(&url)
397 .map_err(|e| anyhow!("Invalid {} endpoint: {}", self.kind, e))
398 }
399
400 pub fn resolve_delta_url(&self, hash: &str, old_hash: &str) -> anyhow::Result<Option<String>> {
401 let Some(template) = &self.delta_url else {
402 return Ok(None);
403 };
404
405 let url = template
406 .replace("{hash}", hash)
407 .replace("{old_hash}", old_hash);
408 crate::uri::normalize_and_validate_url(&url)
409 .map_err(|e| anyhow!("Invalid {} delta endpoint: {}", self.kind, e))
410 .map(Some)
411 }
412}
413
414#[cfg(test)]
415#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
416mod tests {
417 use super::*;
418
419 fn keypair(seed: u8) -> ([u8; 32], String) {
420 let private_key = [seed; 32];
421 let signing_key = ed25519_dalek::SigningKey::from_bytes(&private_key);
422 let public_key = crate::format_key(
423 crate::KeyAlgorithm::Ed25519,
424 &signing_key.verifying_key().to_bytes(),
425 );
426 (private_key, public_key)
427 }
428
429 fn rotation_entry(
430 from_private: &[u8; 32],
431 from_key: &str,
432 to_key: &str,
433 serial: u64,
434 retired_at_epoch_ms: u64,
435 ) -> KeyHistoryEntry {
436 let statement = build_key_rotation_statement(
437 "example.com",
438 from_key,
439 to_key,
440 serial,
441 retired_at_epoch_ms,
442 );
443 let rotation_signature =
444 crate::compute_signature(&statement, crate::SignatureAlgorithm::Ed25519, from_private)
445 .unwrap();
446 KeyHistoryEntry {
447 key: from_key.to_string(),
448 status: KeyHistoryStatus::Retired,
449 retired_at_epoch_ms,
450 replaced_by: Some(to_key.to_string()),
451 effective_serial: Some(serial),
452 rotation_signature: Some(rotation_signature),
453 revoked_at_epoch_ms: None,
454 }
455 }
456
457 fn sample_cmn_endpoints() -> Vec<CmnEndpoint> {
458 vec![
459 CmnEndpoint {
460 kind: "mycelium".to_string(),
461 url: "https://example.com/cmn/mycelium/{hash}.json".to_string(),
462 hash: "b3.abc123def456".to_string(),
463 hashes: vec![],
464 format: None,
465 delta_url: None,
466 },
467 CmnEndpoint {
468 kind: "spore".to_string(),
469 url: "https://example.com/cmn/spore/{hash}.json".to_string(),
470 hash: String::new(),
471 hashes: vec![],
472 format: None,
473 delta_url: None,
474 },
475 CmnEndpoint {
476 kind: "archive".to_string(),
477 url: "https://example.com/cmn/archive/{hash}.tar.zst".to_string(),
478 hash: String::new(),
479 hashes: vec![],
480 format: Some("tar+zstd".to_string()),
481 delta_url: Some(
482 "https://example.com/cmn/archive/{hash}.from.{old_hash}.tar.zst".to_string(),
483 ),
484 },
485 ]
486 }
487
488 fn sample_capsule() -> CmnCapsuleEntry {
489 CmnCapsuleEntry {
490 uri: "cmn://example.com".to_string(),
491 serial: 1,
492 key: "host-key".to_string(),
493 history: vec![],
494 endpoints: sample_cmn_endpoints(),
495 }
496 }
497
498 #[test]
499 fn test_cmn_entry_serialization() {
500 let entry = CmnEntry::new(vec![CmnCapsuleEntry {
501 uri: "cmn://example.com".to_string(),
502 serial: 1,
503 key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
504 history: vec![],
505 endpoints: sample_cmn_endpoints(),
506 }]);
507
508 let json = serde_json::to_string(&entry).unwrap_or_default();
509 assert!(json.contains("\"$schema\""));
510 assert!(json.contains(CMN_SCHEMA));
511 assert!(json.contains("b3.abc123def456"));
512 assert!(json.contains("\"serial\""));
513 assert!(json.contains("\"history\""));
514 assert!(json.contains("\"endpoints\""));
515 assert!(json.contains("\"key\""));
516 assert!(!json.contains("protocol_versions"));
517
518 let parsed: CmnEntry = serde_json::from_str(&json).unwrap();
519 let capsule = parsed.primary_capsule().unwrap();
520 assert_eq!(parsed.schema, CMN_SCHEMA);
521 assert_eq!(capsule.serial, 1);
522 assert_eq!(capsule.mycelium_hash(), Some("b3.abc123def456"));
523 assert_eq!(
524 capsule.key,
525 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
526 );
527 }
528
529 #[test]
530 fn test_capsule_build_mycelium_url() {
531 let capsule = sample_capsule();
532 let url = capsule.mycelium_url("b3.abc123").unwrap();
533 assert_eq!(url, "https://example.com/cmn/mycelium/b3.abc123.json");
534 }
535
536 #[test]
537 fn test_capsule_build_spore_url() {
538 let capsule = sample_capsule();
539 let url = capsule.spore_url("b3.abc123").unwrap();
540 assert_eq!(url, "https://example.com/cmn/spore/b3.abc123.json");
541 }
542
543 #[test]
544 fn test_capsule_build_archive_url() {
545 let capsule = sample_capsule();
546 let url = capsule.archive_url("b3.abc123").unwrap();
547 assert_eq!(url, "https://example.com/cmn/archive/b3.abc123.tar.zst");
548 }
549
550 #[test]
551 fn test_capsule_build_archive_url_for_format() {
552 let capsule = sample_capsule();
553 let url = capsule
554 .archive_url_for_format("b3.abc123", "tar+zstd")
555 .unwrap();
556 assert_eq!(url, "https://example.com/cmn/archive/b3.abc123.tar.zst");
557 }
558
559 #[test]
560 fn test_capsule_build_archive_delta_url() {
561 let capsule = sample_capsule();
562 let url = capsule
563 .archive_delta_url("b3.new", "b3.old", Some("tar+zstd"))
564 .unwrap()
565 .unwrap();
566 assert_eq!(
567 url,
568 "https://example.com/cmn/archive/b3.new.from.b3.old.tar.zst"
569 );
570 }
571
572 #[test]
573 fn test_capsule_build_taste_url() {
574 let mut endpoints = sample_cmn_endpoints();
575 endpoints.push(CmnEndpoint {
576 kind: "taste".to_string(),
577 url: "https://example.com/cmn/taste/{hash}.json".to_string(),
578 hash: String::new(),
579 hashes: vec![],
580 format: None,
581 delta_url: None,
582 });
583 let capsule = CmnCapsuleEntry {
584 endpoints,
585 ..sample_capsule()
586 };
587 let url = capsule.taste_url("b3.7tRkW2x").unwrap();
588 assert_eq!(url, "https://example.com/cmn/taste/b3.7tRkW2x.json");
589 }
590
591 #[test]
592 fn test_capsule_build_taste_url_not_configured() {
593 let capsule = sample_capsule();
594 assert!(capsule.taste_url("b3.7tRkW2x").is_err());
595 }
596
597 #[test]
598 fn test_capsule_build_url_rejects_malicious_template() {
599 let capsule = CmnCapsuleEntry {
600 uri: "cmn://example.com".to_string(),
601 serial: 1,
602 key: "host-key".to_string(),
603 history: vec![],
604 endpoints: vec![
605 CmnEndpoint {
606 kind: "mycelium".to_string(),
607 url: "file:///etc/passwd?{hash}".to_string(),
608 hash: String::new(),
609 hashes: vec![],
610 format: None,
611 delta_url: None,
612 },
613 CmnEndpoint {
614 kind: "spore".to_string(),
615 url: "gopher://internal/{hash}".to_string(),
616 hash: String::new(),
617 hashes: vec![],
618 format: None,
619 delta_url: None,
620 },
621 CmnEndpoint {
622 kind: "archive".to_string(),
623 url: "http://localhost:9090/{hash}".to_string(),
624 hash: String::new(),
625 hashes: vec![],
626 format: Some("tar+zstd".to_string()),
627 delta_url: None,
628 },
629 ],
630 };
631 assert!(capsule.mycelium_url("b3.abc").is_err());
632 assert!(capsule.spore_url("b3.abc").is_err());
633 assert!(capsule.archive_url("b3.abc").is_err());
634 }
635
636 #[test]
637 fn test_capsule_build_url_rejects_ssrf_template() {
638 let capsule = CmnCapsuleEntry {
639 uri: "cmn://example.com".to_string(),
640 serial: 1,
641 key: "host-key".to_string(),
642 history: vec![],
643 endpoints: vec![
644 CmnEndpoint {
645 kind: "mycelium".to_string(),
646 url: "https://10.0.0.1/cmn/{hash}.json".to_string(),
647 hash: String::new(),
648 hashes: vec![],
649 format: None,
650 delta_url: None,
651 },
652 CmnEndpoint {
653 kind: "spore".to_string(),
654 url: "https://192.168.1.1/cmn/{hash}.json".to_string(),
655 hash: String::new(),
656 hashes: vec![],
657 format: None,
658 delta_url: None,
659 },
660 CmnEndpoint {
661 kind: "archive".to_string(),
662 url: "https://169.254.169.254/cmn/{hash}".to_string(),
663 hash: String::new(),
664 hashes: vec![],
665 format: Some("tar+zstd".to_string()),
666 delta_url: None,
667 },
668 ],
669 };
670 assert!(capsule.mycelium_url("b3.abc").is_err());
671 assert!(capsule.spore_url("b3.abc").is_err());
672 assert!(capsule.archive_url("b3.abc").is_err());
673 }
674
675 #[test]
676 fn test_capsule_confirms_retired_history_key_with_rotation_proof() {
677 let serial = 2;
678 let retired_at_epoch_ms = 1_710_000_000_000;
679 let (previous_private, previous_key) = keypair(3);
680 let (_, current_key) = keypair(4);
681 let capsule = CmnCapsuleEntry {
682 uri: "cmn://example.com".to_string(),
683 serial,
684 key: current_key.clone(),
685 history: vec![rotation_entry(
686 &previous_private,
687 &previous_key,
688 ¤t_key,
689 serial,
690 retired_at_epoch_ms,
691 )],
692 endpoints: vec![],
693 };
694
695 assert!(capsule.confirms_key(¤t_key));
696 assert!(capsule.confirms_key(&previous_key));
697 assert!(!capsule.confirms_key("ed25519.other"));
698 }
699
700 #[test]
701 fn test_capsule_confirms_retired_history_key_only_before_retirement() {
702 let serial = 2;
703 let retired_at_epoch_ms = 1_710_000_000_000;
704 let (previous_private, previous_key) = keypair(5);
705 let (_, current_key) = keypair(6);
706 let capsule = CmnCapsuleEntry {
707 uri: "cmn://example.com".to_string(),
708 serial,
709 key: current_key.clone(),
710 history: vec![rotation_entry(
711 &previous_private,
712 &previous_key,
713 ¤t_key,
714 serial,
715 retired_at_epoch_ms,
716 )],
717 endpoints: vec![],
718 };
719
720 assert_eq!(
721 capsule.key_confirmation_at(¤t_key, retired_at_epoch_ms + 1),
722 Some(KeyConfirmation::Current)
723 );
724 assert_eq!(
725 capsule.key_confirmation_at(&previous_key, retired_at_epoch_ms),
726 Some(KeyConfirmation::Retired {
727 retired_at_epoch_ms
728 })
729 );
730 assert!(capsule.confirms_key_at(&previous_key, retired_at_epoch_ms - 1));
731 assert!(!capsule.confirms_key_at(&previous_key, retired_at_epoch_ms + 1));
732 }
733
734 #[test]
735 fn test_retired_history_uses_entry_effective_serial_after_later_cmn_updates() {
736 let rotation_serial = 2;
737 let current_serial = 3;
738 let retired_at_epoch_ms = 1_710_000_000_000;
739 let (previous_private, previous_key) = keypair(13);
740 let (_, current_key) = keypair(14);
741 let capsule = CmnCapsuleEntry {
742 uri: "cmn://example.com".to_string(),
743 serial: current_serial,
744 key: current_key.clone(),
745 history: vec![rotation_entry(
746 &previous_private,
747 &previous_key,
748 ¤t_key,
749 rotation_serial,
750 retired_at_epoch_ms,
751 )],
752 endpoints: vec![],
753 };
754
755 assert!(capsule.confirms_key_at(&previous_key, retired_at_epoch_ms));
756 capsule.verify_rotation_chain_from(&previous_key).unwrap();
757 }
758
759 #[test]
760 fn test_capsule_rejects_revoked_history_key() {
761 let (_, current_key) = keypair(7);
762 let (_, compromised_key) = keypair(8);
763 let capsule = CmnCapsuleEntry {
764 uri: "cmn://example.com".to_string(),
765 serial: 2,
766 key: current_key,
767 history: vec![KeyHistoryEntry {
768 key: compromised_key.clone(),
769 retired_at_epoch_ms: 1_710_000_000_000,
770 status: KeyHistoryStatus::Revoked,
771 replaced_by: None,
772 effective_serial: None,
773 rotation_signature: None,
774 revoked_at_epoch_ms: Some(1_710_000_000_000),
775 }],
776 endpoints: vec![],
777 };
778
779 assert!(!capsule.confirms_key(&compromised_key));
780 }
781
782 #[test]
783 fn test_rotation_statement_verification_rejects_wrong_fields() {
784 let serial = 2;
785 let retired_at_epoch_ms = 1_710_000_000_000;
786 let (previous_private, previous_key) = keypair(9);
787 let (_, current_key) = keypair(10);
788 let entry = rotation_entry(
789 &previous_private,
790 &previous_key,
791 ¤t_key,
792 serial,
793 retired_at_epoch_ms,
794 );
795 let signature = entry.rotation_signature.as_deref().unwrap();
796
797 verify_key_rotation_statement(
798 "example.com",
799 &previous_key,
800 ¤t_key,
801 serial,
802 retired_at_epoch_ms,
803 signature,
804 )
805 .unwrap();
806 assert!(verify_key_rotation_statement(
807 "evil.example",
808 &previous_key,
809 ¤t_key,
810 serial,
811 retired_at_epoch_ms,
812 signature,
813 )
814 .is_err());
815 assert!(verify_key_rotation_statement(
816 "example.com",
817 ¤t_key,
818 &previous_key,
819 serial,
820 retired_at_epoch_ms,
821 signature,
822 )
823 .is_err());
824 assert!(verify_key_rotation_statement(
825 "example.com",
826 &previous_key,
827 ¤t_key,
828 serial + 1,
829 retired_at_epoch_ms,
830 signature,
831 )
832 .is_err());
833 }
834
835 #[test]
836 fn test_verify_rotation_chain_from_pinned_key() {
837 let serial = 3;
838 let retired_at_epoch_ms = 1_710_000_000_000;
839 let (old_private, old_key) = keypair(11);
840 let (_, current_key) = keypair(12);
841 let capsule = CmnCapsuleEntry {
842 uri: "cmn://example.com".to_string(),
843 serial,
844 key: current_key.clone(),
845 history: vec![rotation_entry(
846 &old_private,
847 &old_key,
848 ¤t_key,
849 serial,
850 retired_at_epoch_ms,
851 )],
852 endpoints: vec![],
853 };
854
855 capsule.verify_rotation_chain_from(&old_key).unwrap();
856 assert!(capsule
857 .verify_rotation_chain_from("ed25519.unknown")
858 .is_err());
859 }
860
861 #[test]
862 fn test_capsules_digest_changes_with_serial_endpoint_or_history() {
863 let mut entry = CmnEntry::new(vec![sample_capsule()]);
864 let original = entry.capsules_digest().unwrap();
865 entry.capsules[0].serial += 1;
866 assert_ne!(entry.capsules_digest().unwrap(), original);
867
868 let mut entry = CmnEntry::new(vec![sample_capsule()]);
869 entry.capsules[0].endpoints[0].url = "https://example.com/other/{hash}.json".to_string();
870 assert_ne!(entry.capsules_digest().unwrap(), original);
871
872 let mut entry = CmnEntry::new(vec![sample_capsule()]);
873 entry.capsules[0].history.push(KeyHistoryEntry {
874 key: "ed25519.history".to_string(),
875 status: KeyHistoryStatus::Revoked,
876 retired_at_epoch_ms: 1,
877 replaced_by: None,
878 effective_serial: None,
879 rotation_signature: None,
880 revoked_at_epoch_ms: Some(1),
881 });
882 assert_ne!(entry.capsules_digest().unwrap(), original);
883 }
884}