Skip to main content

substrate/model/
cmn.rs

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/// CMN Entry - the cmn.json file at /.well-known/cmn.json
9///
10/// Contains an array of capsules, each with a URI, public key, and typed
11/// endpoints for resolving all resource types.
12#[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/// A single capsule entry in cmn.json
21#[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/// A historical public key entry, kept for verified rotations/revocations.
31#[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/// Lifecycle state for a historical key.
48#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
49#[serde(rename_all = "snake_case")]
50pub enum KeyHistoryStatus {
51    /// Normal rotation: old signatures remain valid.
52    #[default]
53    Retired,
54    /// Compromise: clients must not trust signatures by this key.
55    Revoked,
56}
57
58/// Confirmation details for a key accepted by `cmn.json`.
59#[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/// Statement signed by an old domain key to authorize its successor.
77#[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/// A single typed endpoint entry in cmn.json.
88#[derive(Serialize, Deserialize, Debug, Clone)]
89pub struct CmnEndpoint {
90    #[serde(rename = "type")]
91    pub kind: String,
92    pub url: String,
93    /// Primary mycelium content hash (authoritative metadata and featured spores).
94    #[serde(default, skip_serializing_if = "String::is_empty")]
95    pub hash: String,
96    /// Optional overflow shard hashes for large domains (spore lists merged, metadata ignored).
97    #[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    /// Primary mycelium content hash (authoritative metadata).
239    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    /// Overflow shard hashes for large domains (spore lists only, metadata ignored).
246    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                &current_key,
689                serial,
690                retired_at_epoch_ms,
691            )],
692            endpoints: vec![],
693        };
694
695        assert!(capsule.confirms_key(&current_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                &current_key,
714                serial,
715                retired_at_epoch_ms,
716            )],
717            endpoints: vec![],
718        };
719
720        assert_eq!(
721            capsule.key_confirmation_at(&current_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                &current_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            &current_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            &current_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            &current_key,
810            serial,
811            retired_at_epoch_ms,
812            signature,
813        )
814        .is_err());
815        assert!(verify_key_rotation_statement(
816            "example.com",
817            &current_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            &current_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                &current_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}