Skip to main content

mur_common/skill/
publisher_trust.rs

1//! Publisher trust keyring — SSH-style pinned trust roots for skill signature verification.
2//!
3//! `PublisherKeyring` lives at `~/.mur/trust/publishers.yaml` and is seeded on first
4//! use with the MUR official publisher fingerprint.  Downstream units consult
5//! `classify()` to decide whether a DSSE signer is Trusted / Revoked / Unknown.
6
7use anyhow::Context as _;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11/// Pinned MUR official publisher key fingerprint (trust anchor).
12/// Derived via: SHA-256(raw 32-byte pubkey), first 8 hex chars, prefixed "ed25519-".
13/// No other values are hardcoded — all trust decisions flow through the keyring.
14pub const MUR_OFFICIAL_PUBLISHER_KEY_FP: &str = "ed25519-861d2acb";
15
16/// Pinned MUR official **license** key fingerprint (trust anchor for
17/// `OfficialLicense` signatures — a SEPARATE key from the bundle publisher key
18/// above, so the online license-signing key on the server can never sign
19/// official bundles). Same derivation: SHA-256(raw 32-byte pubkey), first 8 hex.
20pub const MUR_OFFICIAL_LICENSE_KEY_FP: &str = "ed25519-71594984";
21
22/// Trust classification for a signer fingerprint.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum PublisherTrust {
25    Trusted,
26    Revoked,
27    Unknown,
28}
29
30/// A single trusted publisher entry.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct TrustedPublisher {
33    pub name: String,
34    pub key_fp: String,
35    #[serde(default)]
36    pub comment: String,
37}
38
39/// Serialisable publisher keyring stored at `~/.mur/trust/publishers.yaml`.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct PublisherKeyring {
42    pub schema_version: u32,
43    #[serde(default)]
44    pub publishers: Vec<TrustedPublisher>,
45    #[serde(default)]
46    pub revoked: Vec<String>,
47}
48
49impl PublisherKeyring {
50    /// Canonical on-disk path for the keyring.
51    pub fn path(mur_home: &Path) -> PathBuf {
52        mur_home.join("trust").join("publishers.yaml")
53    }
54
55    /// Classify a key fingerprint.
56    ///
57    /// **Revoked always takes precedence over Trusted (fail-closed):** a key that
58    /// appears in both `publishers` and `revoked` is classified `Revoked`.
59    pub fn classify(&self, key_fp: &str) -> PublisherTrust {
60        if self.revoked.iter().any(|r| r == key_fp) {
61            return PublisherTrust::Revoked;
62        }
63        if self.publishers.iter().any(|p| p.key_fp == key_fp) {
64            return PublisherTrust::Trusted;
65        }
66        PublisherTrust::Unknown
67    }
68
69    /// Build the seeded keyring containing only the pinned official publisher key.
70    fn seed() -> Self {
71        PublisherKeyring {
72            schema_version: 1,
73            publishers: vec![TrustedPublisher {
74                name: "mur".to_string(),
75                key_fp: MUR_OFFICIAL_PUBLISHER_KEY_FP.to_string(),
76                comment: "MUR official publisher (pinned trust root)".to_string(),
77            }],
78            revoked: Vec::new(),
79        }
80    }
81
82    /// Load the keyring from disk; if absent, seed with the pinned official key and persist.
83    pub fn load_or_seed(mur_home: &Path) -> anyhow::Result<Self> {
84        let p = Self::path(mur_home);
85        if p.exists() {
86            let text =
87                std::fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?;
88            serde_yaml_ng::from_str(&text)
89                .map_err(|e| anyhow::anyhow!("parse publisher keyring: {e}"))
90        } else {
91            let kr = Self::seed();
92            kr.save(mur_home)?;
93            Ok(kr)
94        }
95    }
96
97    /// Persist the keyring to disk using temp-file + rename for atomicity.
98    pub fn save(&self, mur_home: &Path) -> anyhow::Result<()> {
99        let p = Self::path(mur_home);
100        if let Some(parent) = p.parent() {
101            std::fs::create_dir_all(parent)
102                .with_context(|| format!("create dir {}", parent.display()))?;
103        }
104        let yaml = serde_yaml_ng::to_string(self)
105            .map_err(|e| anyhow::anyhow!("serialize publisher keyring: {e}"))?;
106        // Atomic write: write to a sibling .tmp then rename.
107        let tmp_path = p.with_extension("yaml.tmp");
108        std::fs::write(&tmp_path, yaml.as_bytes())
109            .with_context(|| format!("write {}", tmp_path.display()))?;
110        std::fs::rename(&tmp_path, &p)
111            .with_context(|| format!("rename {} -> {}", tmp_path.display(), p.display()))?;
112        Ok(())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn kr() -> PublisherKeyring {
121        PublisherKeyring {
122            schema_version: 1,
123            publishers: vec![TrustedPublisher {
124                name: "mur".into(),
125                key_fp: "ed25519-aabbccdd".into(),
126                comment: "official".into(),
127            }],
128            revoked: vec!["ed25519-deadbeef".into()],
129        }
130    }
131
132    #[test]
133    fn classify_trusted_revoked_unknown() {
134        let k = kr();
135        assert_eq!(k.classify("ed25519-aabbccdd"), PublisherTrust::Trusted);
136        assert_eq!(k.classify("ed25519-deadbeef"), PublisherTrust::Revoked);
137        assert_eq!(k.classify("ed25519-00000000"), PublisherTrust::Unknown);
138    }
139
140    #[test]
141    fn revoked_beats_trusted() {
142        // A key both listed AND revoked must classify Revoked (fail-closed).
143        let k = PublisherKeyring {
144            schema_version: 1,
145            publishers: vec![TrustedPublisher {
146                name: "mur".into(),
147                key_fp: "ed25519-aabbccdd".into(),
148                comment: String::new(),
149            }],
150            revoked: vec!["ed25519-aabbccdd".into()],
151        };
152        assert_eq!(k.classify("ed25519-aabbccdd"), PublisherTrust::Revoked);
153    }
154
155    #[test]
156    fn load_or_seed_creates_file_with_official_key() {
157        let tmp = tempfile::tempdir().expect("tempdir");
158        let kr = PublisherKeyring::load_or_seed(tmp.path()).expect("load_or_seed");
159        assert_eq!(
160            kr.classify(MUR_OFFICIAL_PUBLISHER_KEY_FP),
161            PublisherTrust::Trusted
162        );
163        // File must now exist on disk.
164        assert!(PublisherKeyring::path(tmp.path()).exists());
165        // Round-trip: reload → same result.
166        let kr2 = PublisherKeyring::load_or_seed(tmp.path()).expect("reload");
167        assert_eq!(
168            kr2.classify(MUR_OFFICIAL_PUBLISHER_KEY_FP),
169            PublisherTrust::Trusted
170        );
171    }
172}