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