1use std::io::Write;
34use std::path::{Path, PathBuf};
35
36use mkit_attest::{Registry, TrustRoot};
37
38use crate::exit;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct TrustEntry {
45 pub keyid: String,
46 pub kind: String,
47 pub pubkey_hex: String,
48}
49
50#[must_use]
53pub fn default_trust_roots_path() -> PathBuf {
54 crate::config::xdg_config_home().join("mkit/trust-roots.toml")
55}
56
57pub fn warn_if_unsafe_trust_roots(
63 trust_path: &Path,
64 mkit_dir: &Path,
65 user_provided_flag: bool,
66) -> Result<(), u8> {
67 if user_provided_flag {
68 return Ok(());
69 }
70 if trust_path.starts_with(mkit_dir) {
71 return Err(super::error(
72 &format!(
73 "refusing to use in-repo trust-roots at {} — pass `--trust-roots` \
74 explicitly or move the file to {}",
75 trust_path.display(),
76 default_trust_roots_path().display()
77 ),
78 exit::CONFIG_ERROR,
79 ));
80 }
81 Ok(())
82}
83
84pub fn note_if_missing(trust_path: &Path) {
90 if !trust_path.exists() {
91 let mut stderr = std::io::stderr().lock();
92 let _ = writeln!(
93 stderr,
94 "note: trust-roots file not found at {} — no keys loaded",
95 trust_path.display()
96 );
97 }
98}
99
100#[must_use]
105pub fn parse(text: &str) -> Vec<TrustEntry> {
106 let mut out = Vec::new();
107 let mut in_block = false;
108 let mut keyid = String::new();
109 let mut kind = String::new();
110 let mut pubkey_hex = String::new();
111
112 let flush = |keyid: &str, kind: &str, pubkey_hex: &str, out: &mut Vec<TrustEntry>| {
113 if keyid.is_empty() || pubkey_hex.is_empty() {
114 return;
115 }
116 let Some(pk_bytes) = hex_decode(pubkey_hex) else {
117 return;
118 };
119 if !keyid_matches_pubkey(keyid, &pk_bytes) {
120 let mut stderr = std::io::stderr().lock();
121 let _ = writeln!(
122 stderr,
123 "note: trust-root '{}' dropped — keyid does not match its pubkey_hex",
124 short_keyid(keyid)
125 );
126 return;
127 }
128 out.push(TrustEntry {
129 keyid: keyid.to_owned(),
130 kind: if kind.is_empty() {
131 "ed25519".to_owned()
132 } else {
133 kind.to_owned()
134 },
135 pubkey_hex: pubkey_hex.to_owned(),
136 });
137 };
138
139 for raw in text.lines() {
140 let line = raw.trim();
141 if line.is_empty() || line.starts_with('#') {
142 continue;
143 }
144 if line == "[[trust_root]]" {
145 if in_block {
146 flush(&keyid, &kind, &pubkey_hex, &mut out);
147 }
148 in_block = true;
149 keyid.clear();
150 kind.clear();
151 pubkey_hex.clear();
152 continue;
153 }
154 if !in_block {
155 continue;
156 }
157 let Some((k, v)) = line.split_once('=') else {
158 continue;
159 };
160 let key = k.trim();
161 let val = v.trim().trim_matches('"').to_owned();
162 match key {
163 "keyid" => keyid = val,
164 "kind" | "algorithm" => kind = val,
165 "pubkey_hex" => pubkey_hex = val,
166 _ => {}
167 }
168 }
169 if in_block {
170 flush(&keyid, &kind, &pubkey_hex, &mut out);
171 }
172 out
173}
174
175pub fn load_entries(path: &Path) -> Result<Vec<TrustEntry>, (String, u8)> {
178 match std::fs::read_to_string(path) {
179 Ok(text) => Ok(parse(&text)),
180 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
181 Err(e) => Err((format!("read {}: {e}", path.display()), exit::NOINPUT)),
182 }
183}
184
185pub fn load_registry(path: &Path) -> Result<Registry, (String, u8)> {
188 let entries = load_entries(path)?;
189 let mut reg = Registry::new();
190 for e in &entries {
191 add_entry_to_registry(&mut reg, e);
192 }
193 Ok(reg)
194}
195
196fn add_entry_to_registry(reg: &mut Registry, e: &TrustEntry) {
197 let Some(pk_bytes) = hex_decode(&e.pubkey_hex) else {
198 return;
199 };
200 match e.kind.as_str() {
201 "ed25519" if pk_bytes.len() == 32 => {
202 let mut arr = [0u8; 32];
203 arr.copy_from_slice(&pk_bytes);
204 reg.add(e.keyid.clone(), TrustRoot::Ed25519PubKey(arr));
205 }
206 "p256-sec1" | "p256" => {
207 reg.add(e.keyid.clone(), TrustRoot::P256PubKeySec1(pk_bytes));
208 }
209 "secp256k1" | "secp256k1-sec1" => {
210 reg.add(e.keyid.clone(), TrustRoot::Secp256k1PubKeySec1(pk_bytes));
211 }
212 #[cfg(feature = "bls-threshold")]
213 "bls12381-thr" if pk_bytes.len() == mkit_attest::BLS_THRESHOLD_PUBLIC_KEY_SIZE => {
214 reg.add(
215 e.keyid.clone(),
216 TrustRoot::Bls12381ThresholdPubKey(pk_bytes),
217 );
218 }
219 _ => {}
220 }
221}
222
223#[must_use]
230pub fn find_ed25519_signer<'a>(entries: &'a [TrustEntry], signer: &[u8; 32]) -> Option<&'a str> {
231 entries.iter().find_map(|e| {
232 if e.kind != "ed25519" {
233 return None;
234 }
235 let bytes = hex_decode(&e.pubkey_hex)?;
236 if bytes.len() == 32 && bytes == signer {
237 Some(e.keyid.as_str())
238 } else {
239 None
240 }
241 })
242}
243
244#[must_use]
249pub fn serialize(entries: &[TrustEntry]) -> String {
250 use std::fmt::Write as _;
251 let mut out = String::new();
252 for e in entries {
253 out.push_str("[[trust_root]]\n");
254 let _ = writeln!(out, "keyid = \"{}\"", e.keyid);
255 let _ = writeln!(out, "kind = \"{}\"", e.kind);
256 let _ = writeln!(out, "pubkey_hex = \"{}\"", e.pubkey_hex);
257 out.push('\n');
258 }
259 out
260}
261
262pub fn save(path: &Path, entries: &[TrustEntry]) -> Result<(), (String, u8)> {
264 if let Some(parent) = path.parent()
265 && !parent.as_os_str().is_empty()
266 {
267 std::fs::create_dir_all(parent)
268 .map_err(|e| (format!("create {}: {e}", parent.display()), exit::CANTCREAT))?;
269 }
270 std::fs::write(path, serialize(entries))
271 .map_err(|e| (format!("write {}: {e}", path.display()), exit::CANTCREAT))
272}
273
274#[must_use]
284pub fn keyid_matches_pubkey(keyid: &str, pubkey: &[u8]) -> bool {
285 let Some((prefix, body)) = keyid.split_once(':') else {
286 return true;
287 };
288 let body = body.to_ascii_lowercase();
289 match prefix {
290 "blake3" => {
291 let digest = mkit_core::hash::hash(pubkey);
292 body == mkit_core::hash::to_hex(&digest)
293 }
294 "ed25519" | "secp256k1" | "p256" | "bls12381-thr" => {
295 body == mkit_core::hash::to_hex_bytes(pubkey)
296 }
297 _ => true,
298 }
299}
300
301#[must_use]
303pub fn short_keyid(keyid: &str) -> String {
304 match keyid.split_once(':') {
305 Some((prefix, body)) if body.len() > 16 => {
306 format!("{prefix}:{}…", &body[..16])
307 }
308 _ => keyid.to_owned(),
309 }
310}
311
312#[must_use]
313pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
314 if !s.len().is_multiple_of(2) {
315 return None;
316 }
317 let mut out = Vec::with_capacity(s.len() / 2);
318 let b = s.as_bytes();
319 let mut i = 0;
320 while i < b.len() {
321 let hi = nibble(b[i])?;
322 let lo = nibble(b[i + 1])?;
323 out.push((hi << 4) | lo);
324 i += 2;
325 }
326 Some(out)
327}
328
329fn nibble(c: u8) -> Option<u8> {
330 Some(match c {
331 b'0'..=b'9' => c - b'0',
332 b'a'..=b'f' => 10 + c - b'a',
333 b'A'..=b'F' => 10 + c - b'A',
334 _ => return None,
335 })
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn parse_missing_file_is_empty() {
344 assert!(parse("").is_empty());
345 }
346
347 #[test]
348 fn parse_round_trips_through_serialize() {
349 let hex = "aa".repeat(32);
350 let keyid = format!("ed25519:{hex}");
351 let text = format!(
352 "[[trust_root]]\nkeyid = \"{keyid}\"\nkind = \"ed25519\"\npubkey_hex = \"{hex}\"\n"
353 );
354 let entries = parse(&text);
355 assert_eq!(entries.len(), 1);
356 let re_serialized = serialize(&entries);
357 let re_parsed = parse(&re_serialized);
358 assert_eq!(entries, re_parsed);
359 }
360
361 #[test]
362 fn parse_drops_keyid_pubkey_mismatch() {
363 let keyid_hex = "aa".repeat(32);
364 let wrong_pubkey = "bb".repeat(32);
365 let keyid = format!("ed25519:{keyid_hex}");
366 let text = format!(
367 "[[trust_root]]\nkeyid = \"{keyid}\"\nkind = \"ed25519\"\npubkey_hex = \"{wrong_pubkey}\"\n"
368 );
369 assert!(parse(&text).is_empty());
370 }
371
372 #[test]
373 fn find_ed25519_signer_matches_pubkey_bytes_not_keyid() {
374 let pk = [0x42u8; 32];
375 let hex = mkit_core::hash::to_hex_bytes(&pk);
376 let entries = vec![TrustEntry {
380 keyid: "alice-laptop".to_owned(),
381 kind: "ed25519".to_owned(),
382 pubkey_hex: hex,
383 }];
384 assert_eq!(find_ed25519_signer(&entries, &pk), Some("alice-laptop"));
385 assert_eq!(find_ed25519_signer(&entries, &[0u8; 32]), None);
386 }
387
388 #[test]
389 fn warn_if_unsafe_trust_roots_refuses_in_repo_path_without_explicit_flag() {
390 let mkit_dir = Path::new("/repo/.mkit");
391 let trust_path = mkit_dir.join("trust-roots.toml");
392 let err = warn_if_unsafe_trust_roots(&trust_path, mkit_dir, false).unwrap_err();
393 assert_eq!(err, exit::CONFIG_ERROR);
394 }
395
396 #[test]
397 fn warn_if_unsafe_trust_roots_allows_explicit_flag() {
398 let mkit_dir = Path::new("/repo/.mkit");
399 let trust_path = mkit_dir.join("trust-roots.toml");
400 warn_if_unsafe_trust_roots(&trust_path, mkit_dir, true).unwrap();
401 }
402
403 #[test]
404 fn save_then_load_round_trips() {
405 let td = tempfile::tempdir().unwrap();
406 let path = td.path().join("tr.toml");
407 let hex = "cc".repeat(32);
408 let entries = vec![TrustEntry {
409 keyid: format!("ed25519:{hex}"),
410 kind: "ed25519".to_owned(),
411 pubkey_hex: hex,
412 }];
413 save(&path, &entries).unwrap();
414 let loaded = load_entries(&path).unwrap();
415 assert_eq!(loaded, entries);
416 }
417}