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 pub public_key: String,
32 pub first_seen_at: u64,
34 pub registry_url: String,
35}
36
37pub enum PinCheck {
39 FirstContact,
41 Match,
43 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
76pub 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
107pub 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}