Skip to main content

mkit_cli/commands/
trust_roots.rs

1//! Shared trust-roots TOML file model.
2//!
3//! Three call sites read/write the same `[[trust_root]]` file format:
4//! `mkit trust {add,list,remove}` (`commands/trust.rs`), `mkit verify
5//! --trusted`/`--trust-roots` (`commands/verify.rs`), and `mkit
6//! verify-attest --trust-roots` (`commands/verify_attest.rs`). This
7//! module owns the parser, the writer, and the repo-local path-fencing
8//! policy so none of the three grow a second trust-file format
9//! (issue #693).
10//!
11//! Grammar (a strict subset of TOML):
12//!
13//! ```toml
14//! [[trust_root]]
15//! keyid = "ed25519:..."
16//! kind  = "ed25519"
17//! pubkey_hex = "..."
18//! ```
19//!
20//! * `kind` is one of `ed25519`, `secp256k1` (alias `secp256k1-sec1`),
21//!   `p256-sec1` (alias `p256`), or `bls12381-thr`. Anything else is
22//!   ignored.
23//! * `algorithm` is accepted as an alias for `kind` (per
24//!   `docs/specs/SPEC-RELEASE-THRESHOLD.md`); either field name works.
25//! * `pubkey_hex` is the raw public key bytes in lowercase hex. For
26//!   `bls12381-thr`, the bytes are the 96-byte G2 compressed
27//!   aggregated cohort public key (the `MinSig` variant).
28//!
29//! Lines outside a `[[trust_root]]` block, comments (`#`), and blank
30//! lines are ignored. A missing file parses to zero entries — the
31//! caller's documented "no trust-roots configured" UX.
32
33use std::io::Write;
34use std::path::{Path, PathBuf};
35
36use mkit_attest::{Registry, TrustRoot};
37
38use crate::exit;
39
40/// One validated `[[trust_root]]` block: hex-decoded is deferred to
41/// the consumer (registry build / commit-signer compare) so callers
42/// that only need to list or rewrite the file never touch key bytes.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct TrustEntry {
45    pub keyid: String,
46    pub kind: String,
47    pub pubkey_hex: String,
48}
49
50/// Resolve the user-scoped default trust-roots path:
51/// `$XDG_CONFIG_HOME/mkit/trust-roots.toml`.
52#[must_use]
53pub fn default_trust_roots_path() -> PathBuf {
54    crate::config::xdg_config_home().join("mkit/trust-roots.toml")
55}
56
57/// Refuse to operate on an in-repo trust-roots file unless the user
58/// passed `--trust-roots` explicitly. Without this gate, a hostile
59/// cloned repo could ship `<repo>/.mkit/trust-roots.toml` listing
60/// attacker keys and a trust-consuming command would trust it
61/// implicitly. See `docs/THREAT-MODEL.md` §5 "Trust-roots scope".
62pub 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
84/// Print a hint (not an error) when a trust-roots path passed a safety
85/// check but the file doesn't exist yet. An empty registry means every
86/// signer check will fail closed; the caller's own report loop covers
87/// the substance, this is just so a first-time user isn't left
88/// wondering why nothing verified.
89pub 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/// Parse every `[[trust_root]]` block in `text`. Blocks with missing
101/// `keyid`/`pubkey_hex`, unparsable hex, or a keyid/pubkey mismatch
102/// (#223) are dropped with a stderr note rather than surfaced as a
103/// hard error — matches `verify-attest`'s tolerant-parser policy.
104#[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
175/// Load and parse the trust-roots file at `path`. A missing file
176/// parses to an empty list (not an error).
177pub 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
185/// Load the trust-roots file at `path` into an `mkit-attest` [`Registry`],
186/// for DSSE attestation verification (`mkit verify-attest`).
187pub 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/// Does `entries` contain a live `ed25519` trust root whose pubkey
224/// bytes equal `signer`? Used by `mkit verify --trusted` to cross-check
225/// a commit/remix/tag's embedded `signer` field — commit signing is
226/// Ed25519-only today (issue #693 implementation notes).
227///
228/// Returns the matching entry's `keyid` on success.
229#[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/// Serialize `entries` back to the `[[trust_root]]` file grammar.
245/// Round-trips through `parse` (modulo comments — this rewrites the
246/// whole file, so any hand-added comments in a file `mkit trust`
247/// subsequently edits are NOT preserved).
248#[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
262/// Write `entries` to `path`, creating parent directories as needed.
263pub 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/// Cross-check (#223) that `keyid` is consistent with the declared
275/// public key bytes. The canonical keyid shape is `<prefix>:<body>`:
276///
277/// - `blake3:<hex>` — body is `blake3(pubkey)`; verify the digest.
278/// - `ed25519` / `secp256k1` / `p256` / `bls12381-thr:<hex>` — body is
279///   the raw lowercase-hex pubkey; verify it equals `pubkey_hex`.
280/// - Anything else (unknown prefix, no `:` separator) is left
281///   uncross-checked here — return `true` so forward-compatible
282///   keyids are not dropped.
283#[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/// Shorten a keyid for display: `<prefix>:<first-16-hex>…`.
302#[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        // Deliberately use a human label instead of the "ed25519:<hex>"
377        // convention — the commit-trust check must key off pubkey
378        // bytes, not a specific keyid shape.
379        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}