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 crate::trust::test_env_lock::MUR_HOME_LOCK;
141    use chrono::Duration;
142
143    fn make_list(crl: u64, expires_offset_secs: i64) -> RevocationsList {
144        let now = Utc::now();
145        RevocationsList {
146            version: 1,
147            this_update: now,
148            next_update: now + Duration::hours(24),
149            expires_at: now + Duration::seconds(expires_offset_secs),
150            crl_number: crl,
151            revoked: vec![
152                RevokedEntry::Package {
153                    manifest_hash: "sha256:deadbeef".into(),
154                    reason: "test".into(),
155                    revoked_at: now,
156                },
157                RevokedEntry::Author {
158                    pubkey: "ed25519:abc123".into(),
159                    reason: "test".into(),
160                    revoked_at: now,
161                },
162            ],
163        }
164    }
165
166    fn serialize(list: &RevocationsList) -> Vec<u8> {
167        serde_json::to_vec(list).unwrap()
168    }
169
170    #[test]
171    fn parse_valid_list() {
172        let list = make_list(1, 3600);
173        let bytes = serialize(&list);
174        let parsed = RevocationsList::parse_and_validate(&bytes, None).unwrap();
175        assert_eq!(parsed.crl_number, 1);
176        assert_eq!(parsed.revoked.len(), 2);
177    }
178
179    #[test]
180    fn rejects_crl_rollback() {
181        let list = make_list(5, 3600);
182        let bytes = serialize(&list);
183        // known_crl >= new crl_number → rejected
184        let err = RevocationsList::parse_and_validate(&bytes, Some(5)).unwrap_err();
185        assert!(err.to_string().contains("crl_number"));
186        let err2 = RevocationsList::parse_and_validate(&bytes, Some(6)).unwrap_err();
187        assert!(err2.to_string().contains("crl_number"));
188    }
189
190    #[test]
191    fn accepts_first_fetch() {
192        let list = make_list(1, 3600);
193        let bytes = serialize(&list);
194        // None known_crl → always accepted
195        RevocationsList::parse_and_validate(&bytes, None).unwrap();
196    }
197
198    #[test]
199    fn is_expired_past() {
200        let list = make_list(1, -60); // expired 1 minute ago
201        assert!(list.is_expired());
202    }
203
204    #[test]
205    fn is_expired_future() {
206        let list = make_list(1, 3600);
207        assert!(!list.is_expired());
208    }
209
210    #[test]
211    fn rejects_already_expired_on_parse() {
212        let list = make_list(1, -60);
213        let bytes = serialize(&list);
214        let err = RevocationsList::parse_and_validate(&bytes, None).unwrap_err();
215        assert!(err.to_string().contains("expired"));
216    }
217
218    #[test]
219    fn package_revocation_lookup() {
220        let list = make_list(1, 3600);
221        assert!(list.is_package_revoked("sha256:deadbeef"));
222        assert!(!list.is_package_revoked("sha256:00000000"));
223    }
224
225    #[test]
226    fn author_revocation_lookup() {
227        let list = make_list(1, 3600);
228        assert!(list.is_author_revoked("ed25519:abc123"));
229        assert!(!list.is_author_revoked("ed25519:nothere"));
230    }
231
232    #[test]
233    fn cache_roundtrip() {
234        let _guard = MUR_HOME_LOCK.lock().unwrap();
235        let tmp = tempfile::TempDir::new().unwrap();
236        let prev_home = std::env::var_os("MUR_HOME");
237        unsafe { std::env::set_var("MUR_HOME", tmp.path()) };
238
239        let list = make_list(42, 3600);
240        list.save_cached(tmp.path()).unwrap();
241        let loaded = RevocationsList::load_cached(tmp.path()).unwrap();
242        assert_eq!(loaded.crl_number, 42);
243        assert_eq!(loaded.revoked.len(), 2);
244
245        unsafe {
246            if let Some(p) = prev_home {
247                std::env::set_var("MUR_HOME", p);
248            } else {
249                std::env::remove_var("MUR_HOME");
250            }
251        }
252    }
253}