Skip to main content

mkit_cli/commands/
trust.rs

1//! `mkit trust` — manage the commit-history allowed-signers file that
2//! `mkit verify --trusted` cross-checks a commit/remix/tag's `signer`
3//! against.
4//!
5//! ```text
6//! mkit trust add <keyid> <pubkey-hex> [--kind ed25519|p256-sec1|secp256k1|bls12381-thr]
7//!                [--trust-roots <path>] [--force]
8//! mkit trust list [--trust-roots <path>] [--json]
9//! mkit trust remove <keyid> [--trust-roots <path>] --yes
10//! ```
11//!
12//! The file is the same `[[trust_root]]` TOML format `mkit
13//! verify-attest --trust-roots` already reads (see
14//! `commands/trust_roots.rs`) — one registry, shared by DSSE
15//! attestation verification and commit/remix/tag signer verification,
16//! keyed by the `TrustRoot` type `mkit-attest` already exposes. Path
17//! defaults to the user-scoped `$XDG_CONFIG_HOME/mkit/trust-roots.toml`;
18//! an in-repo path is refused unless passed explicitly via
19//! `--trust-roots` (same hostile-clone defense as `verify-attest`, see
20//! `docs/THREAT-MODEL.md` §5).
21
22use std::io::Write as _;
23use std::path::PathBuf;
24
25use clap::{Parser, Subcommand};
26
27use super::trust_roots::{
28    self, TrustEntry, default_trust_roots_path, keyid_matches_pubkey, warn_if_unsafe_trust_roots,
29};
30use crate::clap_shim;
31use crate::exit;
32
33#[derive(Debug, Parser)]
34#[command(
35    name = "mkit trust",
36    about = "Manage the commit-history trust-roots file."
37)]
38struct TrustOpts {
39    #[command(subcommand)]
40    command: TrustCommand,
41}
42
43#[derive(Debug, Subcommand)]
44enum TrustCommand {
45    /// Add (or replace) a trusted signer.
46    Add(AddOpts),
47    /// List trusted signers.
48    List(ListOpts),
49    /// Remove a trusted signer.
50    Remove(RemoveOpts),
51}
52
53#[derive(Debug, Parser)]
54struct AddOpts {
55    /// Identifier for this trust root, e.g. `ed25519:<hex-pubkey>` or a
56    /// human label like `alice-laptop`. Free-form, but see `kind` for
57    /// the canonical `<algorithm>:<hex-pubkey>` shape.
58    keyid: String,
59    /// Public key, lowercase hex. Ed25519 is 32 bytes; P-256/secp256k1
60    /// SEC1 are 33 (compressed) or 65 (uncompressed) bytes; the
61    /// BLS12-381 threshold cohort key (`bls-threshold` feature) is 96
62    /// bytes.
63    pubkey_hex: String,
64    /// Trust-root kind. Commit/remix/tag signing is Ed25519-only
65    /// today, so this defaults to `ed25519`; other kinds only matter
66    /// for `mkit verify-attest`.
67    #[arg(long, value_name = "KIND", default_value = "ed25519")]
68    kind: String,
69    #[arg(long, value_name = "PATH")]
70    trust_roots: Option<String>,
71    /// Overwrite an existing entry for this keyid.
72    #[arg(long)]
73    force: bool,
74}
75
76#[derive(Debug, Parser)]
77struct ListOpts {
78    #[arg(long, value_name = "PATH")]
79    trust_roots: Option<String>,
80    #[arg(long)]
81    json: bool,
82}
83
84#[derive(Debug, Parser)]
85struct RemoveOpts {
86    keyid: String,
87    #[arg(long, value_name = "PATH")]
88    trust_roots: Option<String>,
89    #[arg(long)]
90    yes: bool,
91}
92
93#[must_use]
94pub fn run(args: &[String]) -> u8 {
95    let opts = match clap_shim::parse::<TrustOpts>("mkit trust", args) {
96        Ok(opts) => opts,
97        Err(code) => return code,
98    };
99    match opts.command {
100        TrustCommand::Add(opts) => add(&opts),
101        TrustCommand::List(opts) => list(&opts),
102        TrustCommand::Remove(opts) => remove(&opts),
103    }
104}
105
106/// Resolve the trust-roots path from an optional CLI flag, honoring
107/// the same repo-local path-fencing every trust-consuming command
108/// applies.
109fn resolve_path(flag: Option<&str>) -> Result<PathBuf, u8> {
110    let path = flag.map_or_else(default_trust_roots_path, PathBuf::from);
111    // `mkit trust` has no repo context of its own (unlike `verify` /
112    // `verify-attest`, which resolve a `.mkit` dir to fence against) —
113    // it only needs to refuse an explicit-looking-but-actually-default
114    // in-repo path when the CWD happens to be a repo. Fence against
115    // `.mkit` under the current directory if one exists; otherwise
116    // there is nothing to fence.
117    let cwd = std::env::current_dir().unwrap_or_default();
118    let mkit_dir = cwd.join(".mkit");
119    warn_if_unsafe_trust_roots(&path, &mkit_dir, flag.is_some())?;
120    Ok(path)
121}
122
123fn add(opts: &AddOpts) -> u8 {
124    let path = match resolve_path(opts.trust_roots.as_deref()) {
125        Ok(p) => p,
126        Err(code) => return code,
127    };
128    let Some(pk_bytes) = trust_roots::hex_decode(&opts.pubkey_hex) else {
129        return emit_err(
130            &format!("bad --pubkey-hex '{}': not valid hex", opts.pubkey_hex),
131            exit::USAGE,
132        );
133    };
134    if let Some(expected_len) = expected_pubkey_len(&opts.kind)
135        && pk_bytes.len() != expected_len
136    {
137        return emit_err(
138            &format!(
139                "bad pubkey length for kind '{}': expected {expected_len} bytes, got {}",
140                opts.kind,
141                pk_bytes.len()
142            ),
143            exit::USAGE,
144        );
145    }
146    if !keyid_matches_pubkey(&opts.keyid, &pk_bytes) {
147        return emit_err(
148            &format!(
149                "keyid '{}' does not match the given pubkey — a `<algorithm>:<hex>` keyid must \
150                 embed the same hex as --pubkey-hex (or the blake3 digest of it)",
151                opts.keyid
152            ),
153            exit::USAGE,
154        );
155    }
156    let mut entries = match trust_roots::load_entries(&path) {
157        Ok(e) => e,
158        Err((msg, code)) => return emit_err(&msg, code),
159    };
160    if let Some(existing) = entries.iter().position(|e| e.keyid == opts.keyid) {
161        if !opts.force {
162            return emit_err(
163                &format!(
164                    "a trust root for keyid '{}' already exists — pass --force to replace it",
165                    opts.keyid
166                ),
167                exit::USAGE,
168            );
169        }
170        entries.remove(existing);
171    }
172    entries.push(TrustEntry {
173        keyid: opts.keyid.clone(),
174        kind: opts.kind.clone(),
175        pubkey_hex: opts.pubkey_hex.to_ascii_lowercase(),
176    });
177    if let Err((msg, code)) = trust_roots::save(&path, &entries) {
178        return emit_err(&msg, code);
179    }
180    let mut stdout = std::io::stdout().lock();
181    let _ = writeln!(
182        stdout,
183        "added {} ({}) to {}",
184        opts.keyid,
185        opts.kind,
186        path.display()
187    );
188    exit::OK
189}
190
191fn list(opts: &ListOpts) -> u8 {
192    let path = match resolve_path(opts.trust_roots.as_deref()) {
193        Ok(p) => p,
194        Err(code) => return code,
195    };
196    let entries = match trust_roots::load_entries(&path) {
197        Ok(e) => e,
198        Err((msg, code)) => return emit_err(&msg, code),
199    };
200    let mut stdout = std::io::stdout().lock();
201    if opts.json {
202        use std::fmt::Write as _;
203        let mut out = String::from("[");
204        for (i, e) in entries.iter().enumerate() {
205            if i > 0 {
206                out.push(',');
207            }
208            let _ = write!(
209                out,
210                "{{\"keyid\":{:?},\"kind\":{:?},\"pubkey_hex\":{:?}}}",
211                e.keyid, e.kind, e.pubkey_hex
212            );
213        }
214        out.push(']');
215        let _ = writeln!(stdout, "{out}");
216    } else if entries.is_empty() {
217        let _ = writeln!(stdout, "no trust roots in {}", path.display());
218    } else {
219        for e in &entries {
220            let _ = writeln!(stdout, "{}  [{}]  {}", e.keyid, e.kind, e.pubkey_hex);
221        }
222    }
223    exit::OK
224}
225
226fn remove(opts: &RemoveOpts) -> u8 {
227    if !opts.yes {
228        return emit_err("mkit trust remove requires --yes", exit::USAGE);
229    }
230    let path = match resolve_path(opts.trust_roots.as_deref()) {
231        Ok(p) => p,
232        Err(code) => return code,
233    };
234    let mut entries = match trust_roots::load_entries(&path) {
235        Ok(e) => e,
236        Err((msg, code)) => return emit_err(&msg, code),
237    };
238    let Some(pos) = entries.iter().position(|e| e.keyid == opts.keyid) else {
239        return emit_err(
240            &format!("no trust root registered for keyid '{}'", opts.keyid),
241            exit::GENERAL_ERROR,
242        );
243    };
244    entries.remove(pos);
245    if let Err((msg, code)) = trust_roots::save(&path, &entries) {
246        return emit_err(&msg, code);
247    }
248    let mut stdout = std::io::stdout().lock();
249    let _ = writeln!(stdout, "removed {} from {}", opts.keyid, path.display());
250    exit::OK
251}
252
253fn expected_pubkey_len(kind: &str) -> Option<usize> {
254    match kind {
255        "ed25519" => Some(32),
256        #[cfg(feature = "bls-threshold")]
257        "bls12381-thr" => Some(mkit_attest::BLS_THRESHOLD_PUBLIC_KEY_SIZE),
258        // SEC1 p256/secp256k1 accept both 33 (compressed) and 65
259        // (uncompressed) — length-checked by mkit-attest at verify
260        // time instead of here.
261        _ => None,
262    }
263}
264
265use super::error as emit_err;
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::fs;
271
272    fn parse_args(args: &[String]) -> Result<TrustOpts, clap::Error> {
273        let mut full: Vec<String> = vec!["mkit trust".into()];
274        full.extend_from_slice(args);
275        TrustOpts::try_parse_from(full)
276    }
277
278    #[test]
279    fn parse_add_defaults_kind_to_ed25519() {
280        let args = vec!["add".into(), "keyid".into(), "aa".into()];
281        let TrustCommand::Add(opts) = parse_args(&args).unwrap().command else {
282            panic!("expected Add");
283        };
284        assert_eq!(opts.kind, "ed25519");
285        assert!(!opts.force);
286    }
287
288    #[test]
289    fn parse_remove_requires_yes_flag_at_runtime_not_parse_time() {
290        let args = vec!["remove".into(), "keyid".into()];
291        let TrustCommand::Remove(opts) = parse_args(&args).unwrap().command else {
292            panic!("expected Remove");
293        };
294        assert!(!opts.yes);
295    }
296
297    #[test]
298    fn add_list_remove_round_trip() {
299        let td = tempfile::tempdir().unwrap();
300        let path = td.path().join("trust-roots.toml");
301        let hex = "11".repeat(32);
302        let keyid = format!("ed25519:{hex}");
303
304        let rc = add(&AddOpts {
305            keyid: keyid.clone(),
306            pubkey_hex: hex.clone(),
307            kind: "ed25519".into(),
308            trust_roots: Some(path.to_string_lossy().into_owned()),
309            force: false,
310        });
311        assert_eq!(rc, exit::OK);
312
313        let entries = trust_roots::load_entries(&path).unwrap();
314        assert_eq!(entries.len(), 1);
315        assert_eq!(entries[0].keyid, keyid);
316
317        let rc = remove(&RemoveOpts {
318            keyid: keyid.clone(),
319            trust_roots: Some(path.to_string_lossy().into_owned()),
320            yes: true,
321        });
322        assert_eq!(rc, exit::OK);
323        assert!(trust_roots::load_entries(&path).unwrap().is_empty());
324        let _ = fs::remove_dir_all(td.path());
325    }
326
327    #[test]
328    fn add_rejects_keyid_pubkey_mismatch() {
329        let td = tempfile::tempdir().unwrap();
330        let path = td.path().join("trust-roots.toml");
331        let hex = "22".repeat(32);
332        let rc = add(&AddOpts {
333            keyid: format!("ed25519:{}", "ff".repeat(32)),
334            pubkey_hex: hex,
335            kind: "ed25519".into(),
336            trust_roots: Some(path.to_string_lossy().into_owned()),
337            force: false,
338        });
339        assert_eq!(rc, exit::USAGE);
340    }
341
342    #[test]
343    fn add_without_force_refuses_duplicate_keyid() {
344        let td = tempfile::tempdir().unwrap();
345        let path = td.path().join("trust-roots.toml");
346        let hex = "33".repeat(32);
347        let keyid = format!("ed25519:{hex}");
348        let make = || AddOpts {
349            keyid: keyid.clone(),
350            pubkey_hex: hex.clone(),
351            kind: "ed25519".into(),
352            trust_roots: Some(path.to_string_lossy().into_owned()),
353            force: false,
354        };
355        assert_eq!(add(&make()), exit::OK);
356        assert_eq!(add(&make()), exit::USAGE);
357        let mut forced = make();
358        forced.force = true;
359        assert_eq!(add(&forced), exit::OK);
360        assert_eq!(trust_roots::load_entries(&path).unwrap().len(), 1);
361    }
362
363    #[test]
364    fn remove_without_yes_is_refused() {
365        let td = tempfile::tempdir().unwrap();
366        let path = td.path().join("trust-roots.toml");
367        let rc = remove(&RemoveOpts {
368            keyid: "anything".into(),
369            trust_roots: Some(path.to_string_lossy().into_owned()),
370            yes: false,
371        });
372        assert_eq!(rc, exit::USAGE);
373    }
374
375    #[test]
376    fn remove_unknown_keyid_is_an_error() {
377        let td = tempfile::tempdir().unwrap();
378        let path = td.path().join("trust-roots.toml");
379        let rc = remove(&RemoveOpts {
380            keyid: "nope".into(),
381            trust_roots: Some(path.to_string_lossy().into_owned()),
382            yes: true,
383        });
384        assert_eq!(rc, exit::GENERAL_ERROR);
385    }
386
387    #[test]
388    fn list_json_emits_valid_array_shape() {
389        let td = tempfile::tempdir().unwrap();
390        let path = td.path().join("trust-roots.toml");
391        let hex = "44".repeat(32);
392        let keyid = format!("ed25519:{hex}");
393        add(&AddOpts {
394            keyid,
395            pubkey_hex: hex,
396            kind: "ed25519".into(),
397            trust_roots: Some(path.to_string_lossy().into_owned()),
398            force: false,
399        });
400        let rc = list(&ListOpts {
401            trust_roots: Some(path.to_string_lossy().into_owned()),
402            json: true,
403        });
404        assert_eq!(rc, exit::OK);
405    }
406}