Skip to main content

dkp_core/
trust.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8
9const TRUSTED_KEYS_FILE: &str = ".dkp/trusted_keys.json";
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct TrustedKeysFile {
13    #[serde(default)]
14    pub version: u32,
15    #[serde(default)]
16    pub scopes: HashMap<String, TrustedKeyEntry>,
17}
18
19impl Default for TrustedKeysFile {
20    fn default() -> Self {
21        Self {
22            version: 1,
23            scopes: HashMap::new(),
24        }
25    }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TrustedKeyEntry {
30    /// Base64-encoded Ed25519 public key.
31    pub public_key: String,
32    /// Unix timestamp (seconds) when this scope was first pinned.
33    pub first_seen_at: u64,
34    pub registry_url: String,
35}
36
37/// Outcome of checking a fetched publisher key against the trusted-key store.
38pub enum PinCheck {
39    /// No prior pin existed; the key has now been recorded.
40    FirstContact,
41    /// A prior pin existed and matches the freshly fetched key.
42    Match,
43    /// A prior pin existed and differs from the freshly fetched key.
44    Mismatch { pinned_key: String },
45}
46
47fn trusted_keys_path() -> Result<PathBuf> {
48    let home = dirs::home_dir().context("cannot determine home directory")?;
49    Ok(home.join(TRUSTED_KEYS_FILE))
50}
51
52pub fn load(path: &Path) -> Result<TrustedKeysFile> {
53    if !path.exists() {
54        return Ok(TrustedKeysFile::default());
55    }
56    let contents = std::fs::read_to_string(path)
57        .with_context(|| format!("reading trusted keys from {}", path.display()))?;
58    serde_json::from_str(&contents)
59        .with_context(|| format!("parsing trusted keys file {}", path.display()))
60}
61
62pub fn save(path: &Path, file: &TrustedKeysFile) -> Result<()> {
63    if let Some(parent) = path.parent() {
64        std::fs::create_dir_all(parent)?;
65    }
66    std::fs::write(path, serde_json::to_string_pretty(file)?)
67        .with_context(|| format!("writing trusted keys to {}", path.display()))?;
68    #[cfg(unix)]
69    {
70        use std::os::unix::fs::PermissionsExt;
71        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
72    }
73    Ok(())
74}
75
76/// Check `scope`'s freshly-fetched `public_key` against the global trusted-key
77/// store, pinning it on first contact. Does not itself decide policy on
78/// mismatch — the caller decides whether to hard-fail or accept an override.
79pub fn check_and_pin(scope: &str, public_key: &str, registry_url: &str) -> Result<PinCheck> {
80    let path = trusted_keys_path()?;
81    let mut file = load(&path)?;
82
83    match file.scopes.get(scope) {
84        None => {
85            let first_seen_at = std::time::SystemTime::now()
86                .duration_since(std::time::UNIX_EPOCH)
87                .map(|d| d.as_secs())
88                .unwrap_or(0);
89            file.scopes.insert(
90                scope.to_owned(),
91                TrustedKeyEntry {
92                    public_key: public_key.to_owned(),
93                    first_seen_at,
94                    registry_url: registry_url.to_owned(),
95                },
96            );
97            save(&path, &file)?;
98            Ok(PinCheck::FirstContact)
99        }
100        Some(entry) if entry.public_key == public_key => Ok(PinCheck::Match),
101        Some(entry) => Ok(PinCheck::Mismatch {
102            pinned_key: entry.public_key.clone(),
103        }),
104    }
105}
106
107/// Overwrite the pinned key for `scope`, used after the user explicitly
108/// accepts a key change (e.g. via `--accept-new-key`).
109pub fn accept_new_key(scope: &str, public_key: &str, registry_url: &str) -> Result<()> {
110    let path = trusted_keys_path()?;
111    let mut file = load(&path)?;
112    let first_seen_at = std::time::SystemTime::now()
113        .duration_since(std::time::UNIX_EPOCH)
114        .map(|d| d.as_secs())
115        .unwrap_or(0);
116    file.scopes.insert(
117        scope.to_owned(),
118        TrustedKeyEntry {
119            public_key: public_key.to_owned(),
120            first_seen_at,
121            registry_url: registry_url.to_owned(),
122        },
123    );
124    save(&path, &file)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn first_contact_pins_and_reports_first_contact() {
133        let dir = tempfile::tempdir().unwrap();
134        let path = dir.path().join("trusted_keys.json");
135        let mut file = load(&path).unwrap();
136        assert!(file.scopes.is_empty());
137
138        file.scopes.insert(
139            "@scope/pack".into(),
140            TrustedKeyEntry {
141                public_key: "keyA".into(),
142                first_seen_at: 0,
143                registry_url: "https://example.test".into(),
144            },
145        );
146        save(&path, &file).unwrap();
147
148        let reloaded = load(&path).unwrap();
149        assert_eq!(
150            reloaded.scopes.get("@scope/pack").unwrap().public_key,
151            "keyA"
152        );
153    }
154
155    #[test]
156    fn load_missing_file_returns_default() {
157        let dir = tempfile::tempdir().unwrap();
158        let path = dir.path().join("does-not-exist.json");
159        let file = load(&path).unwrap();
160        assert_eq!(file.version, 1);
161        assert!(file.scopes.is_empty());
162    }
163}