Skip to main content

mur_common/trust/
revocations.rs

1//! Revocations list consumer — spec §7.4.1.
2//!
3//! v1 ships the consumer-side types, monotonicity check, expiry check, and
4//! local cache. DSSE signature verification against the mur root key is
5//! deferred to V2 (no root key material exists in v1). The format is
6//! forward-compatible: a v1 consumer can parse and cache a v2-signed list,
7//! it just won't enforce the signature yet.
8
9use crate::muragent::MuragentError;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14/// A single revoked item — either a specific package or an entire author.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(tag = "kind", rename_all = "kebab-case")]
17pub enum RevokedEntry {
18    Package {
19        /// SHA-256 hash of manifest.signed.json, prefixed `sha256:<hex>`.
20        manifest_hash: String,
21        reason: String,
22        revoked_at: DateTime<Utc>,
23    },
24    Author {
25        /// Ed25519 public key, prefixed `ed25519:<base64>`.
26        pubkey: String,
27        reason: String,
28        revoked_at: DateTime<Utc>,
29    },
30}
31
32/// The signed revocations list fetched from `https://mur.run/revocations.json`.
33///
34/// Modeled on TUF's `timestamp.json` role. Outer DSSE envelope is stripped
35/// during fetch; this struct represents the payload.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RevocationsList {
38    pub version: u32,
39    pub this_update: DateTime<Utc>,
40    pub next_update: DateTime<Utc>,
41    /// After this timestamp Hub refuses to operate until the list is refreshed.
42    pub expires_at: DateTime<Utc>,
43    /// Monotonically increasing counter. Hub rejects any list whose
44    /// `crl_number` is ≤ the last accepted value (rollback / clock-rollback
45    /// defence per spec §7.4.1).
46    pub crl_number: u64,
47    pub revoked: Vec<RevokedEntry>,
48}
49
50impl RevocationsList {
51    /// Parse from raw JSON bytes and validate monotonicity + expiry.
52    ///
53    /// `known_crl` is the `crl_number` from the last accepted list (or `None`
54    /// on first fetch — in which case any validly-signed list is accepted).
55    pub fn parse_and_validate(bytes: &[u8], known_crl: Option<u64>) -> Result<Self, MuragentError> {
56        let list: Self = serde_json::from_slice(bytes)
57            .map_err(|e| MuragentError::Other(format!("revocations parse: {e}")))?;
58
59        if list.version != 1 {
60            return Err(MuragentError::Other(format!(
61                "revocations: unsupported version {}",
62                list.version
63            )));
64        }
65
66        // Monotonicity: reject rollback even under clock-rollback attack.
67        if let Some(n) = known_crl
68            && list.crl_number <= n
69        {
70            return Err(MuragentError::Other(format!(
71                "revocations: crl_number {} is not greater than known {}",
72                list.crl_number, n
73            )));
74        }
75
76        if list.expires_at < Utc::now() {
77            return Err(MuragentError::Other(
78                "revocations: list is already expired".into(),
79            ));
80        }
81
82        // TODO(M-export-V2): verify DSSE envelope signature against mur root key.
83
84        Ok(list)
85    }
86
87    /// Returns `true` if the list's `expires_at` is in the past.
88    pub fn is_expired(&self) -> bool {
89        Utc::now() > self.expires_at
90    }
91
92    /// Returns `true` if the given manifest hash appears in the revoked list.
93    ///
94    /// `manifest_hash` should be the `sha256:<hex>` string from the manifest.
95    pub fn is_package_revoked(&self, manifest_hash: &str) -> bool {
96        self.revoked.iter().any(
97            |e| matches!(e, RevokedEntry::Package { manifest_hash: h, .. } if h == manifest_hash),
98        )
99    }
100
101    /// Returns `true` if the given author pubkey is revoked.
102    ///
103    /// `pubkey` should be the `ed25519:<base64>` string from the manifest.
104    pub fn is_author_revoked(&self, pubkey: &str) -> bool {
105        self.revoked
106            .iter()
107            .any(|e| matches!(e, RevokedEntry::Author { pubkey: k, .. } if k == pubkey))
108    }
109
110    /// Load the locally-cached revocations list from `<mur_home>/trust/revocations.json`.
111    ///
112    /// Returns `None` if no cache file exists or if the file cannot be parsed.
113    /// Callers that only need the cached `crl_number` should unwrap and read
114    /// `list.crl_number`.
115    pub fn load_cached(mur_home: &Path) -> Option<Self> {
116        let path = mur_home.join("trust").join("revocations.json");
117        let bytes = std::fs::read(&path).ok()?;
118        serde_json::from_slice(&bytes).ok()
119    }
120
121    /// Atomically write the revocations list to the local cache.
122    ///
123    /// Uses temp-file + rename for crash-safety (same pattern as TrustStore).
124    pub fn save_cached(&self, mur_home: &Path) -> Result<(), MuragentError> {
125        let dir = mur_home.join("trust");
126        std::fs::create_dir_all(&dir).map_err(MuragentError::Io)?;
127        let path = dir.join("revocations.json");
128        let tmp = path.with_extension("json.tmp");
129        let json = serde_json::to_vec_pretty(self)
130            .map_err(|e| MuragentError::Other(format!("revocations serialize: {e}")))?;
131        std::fs::write(&tmp, &json).map_err(MuragentError::Io)?;
132        std::fs::rename(&tmp, &path).map_err(MuragentError::Io)?;
133        Ok(())
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use chrono::Duration;
141
142    fn make_list(crl: u64, expires_offset_secs: i64) -> RevocationsList {
143        let now = Utc::now();
144        RevocationsList {
145            version: 1,
146            this_update: now,
147            next_update: now + Duration::hours(24),
148            expires_at: now + Duration::seconds(expires_offset_secs),
149            crl_number: crl,
150            revoked: vec![
151                RevokedEntry::Package {
152                    manifest_hash: "sha256:deadbeef".into(),
153                    reason: "test".into(),
154                    revoked_at: now,
155                },
156                RevokedEntry::Author {
157                    pubkey: "ed25519:abc123".into(),
158                    reason: "test".into(),
159                    revoked_at: now,
160                },
161            ],
162        }
163    }
164
165    fn serialize(list: &RevocationsList) -> Vec<u8> {
166        serde_json::to_vec(list).unwrap()
167    }
168
169    #[test]
170    fn parse_valid_list() {
171        let list = make_list(1, 3600);
172        let bytes = serialize(&list);
173        let parsed = RevocationsList::parse_and_validate(&bytes, None).unwrap();
174        assert_eq!(parsed.crl_number, 1);
175        assert_eq!(parsed.revoked.len(), 2);
176    }
177
178    #[test]
179    fn rejects_crl_rollback() {
180        let list = make_list(5, 3600);
181        let bytes = serialize(&list);
182        // known_crl >= new crl_number → rejected
183        let err = RevocationsList::parse_and_validate(&bytes, Some(5)).unwrap_err();
184        assert!(err.to_string().contains("crl_number"));
185        let err2 = RevocationsList::parse_and_validate(&bytes, Some(6)).unwrap_err();
186        assert!(err2.to_string().contains("crl_number"));
187    }
188
189    #[test]
190    fn accepts_first_fetch() {
191        let list = make_list(1, 3600);
192        let bytes = serialize(&list);
193        // None known_crl → always accepted
194        RevocationsList::parse_and_validate(&bytes, None).unwrap();
195    }
196
197    #[test]
198    fn is_expired_past() {
199        let list = make_list(1, -60); // expired 1 minute ago
200        assert!(list.is_expired());
201    }
202
203    #[test]
204    fn is_expired_future() {
205        let list = make_list(1, 3600);
206        assert!(!list.is_expired());
207    }
208
209    #[test]
210    fn rejects_already_expired_on_parse() {
211        let list = make_list(1, -60);
212        let bytes = serialize(&list);
213        let err = RevocationsList::parse_and_validate(&bytes, None).unwrap_err();
214        assert!(err.to_string().contains("expired"));
215    }
216
217    #[test]
218    fn package_revocation_lookup() {
219        let list = make_list(1, 3600);
220        assert!(list.is_package_revoked("sha256:deadbeef"));
221        assert!(!list.is_package_revoked("sha256:00000000"));
222    }
223
224    #[test]
225    fn author_revocation_lookup() {
226        let list = make_list(1, 3600);
227        assert!(list.is_author_revoked("ed25519:abc123"));
228        assert!(!list.is_author_revoked("ed25519:nothere"));
229    }
230
231    #[test]
232    fn cache_roundtrip() {
233        let tmp = tempfile::TempDir::new().unwrap();
234        let _env = crate::test_env::EnvGuard::set([("MUR_HOME", tmp.path())]);
235
236        let list = make_list(42, 3600);
237        list.save_cached(tmp.path()).unwrap();
238        let loaded = RevocationsList::load_cached(tmp.path()).unwrap();
239        assert_eq!(loaded.crl_number, 42);
240        assert_eq!(loaded.revoked.len(), 2);
241    }
242}