Skip to main content

mkit_cli/commands/
verify_proof.rs

1//! `mkit verify-proof <commit-id> <bundle>` — verify a disclosure bundle
2//! against a trusted commit id.
3
4use std::io::{self, Read, Write};
5use std::path::PathBuf;
6
7use clap::{Parser, ValueEnum};
8use mkit_core::hash::{from_hex, hash, to_hex, to_hex_bytes};
9use mkit_core::object::EntryMode;
10use mkit_core::verify::{Disclosed, DisclosedPayload, MAX_BUNDLE_BYTES, verify_disclosure};
11
12use super::prove::display_path;
13use super::trust_roots;
14use crate::clap_shim;
15use crate::exit;
16use crate::format::{self, JsonObject};
17
18#[derive(Debug, Clone, Copy, ValueEnum)]
19enum VerifyProofFormat {
20    Default,
21    Json,
22}
23
24#[derive(Debug, Parser)]
25#[command(
26    name = "mkit verify-proof",
27    about = "Verify a disclosure bundle against a trusted 64-hex commit id."
28)]
29struct VerifyProofOpts {
30    /// Trusted 64-hex commit (or remix) id. Not a revision: this command
31    /// does not resolve refs.
32    #[arg(value_name = "COMMIT-ID")]
33    commit_id: String,
34    /// Bundle file, or `-` to read from stdin.
35    #[arg(value_name = "BUNDLE")]
36    bundle: String,
37    /// Fail with dataerr if the authenticated path does not match PATH.
38    /// This is the SPEC-DISCLOSURE rule that the caller compares the
39    /// returned path; the bundle does not authenticate the request.
40    #[arg(long, value_name = "PATH")]
41    expect_path: Option<String>,
42    /// Also cross-check the signer against the trust-roots registry
43    /// (default path). `signature_valid == false` is a failure with this flag.
44    #[arg(long)]
45    trusted: bool,
46    /// Path to a trust-roots TOML file. Implies `--trusted`.
47    #[arg(long, value_name = "PATH")]
48    trust_roots: Option<String>,
49    /// Emit the wasm `verify_disclosure` JSON shape (plus `signer_trusted`).
50    #[arg(long, value_enum, default_value = "default")]
51    format: VerifyProofFormat,
52    /// Write the verified payload bytes (object, chunk, or range) to FILE.
53    #[arg(long, value_name = "FILE")]
54    payload_out: Option<PathBuf>,
55}
56
57#[must_use]
58#[allow(clippy::too_many_lines)]
59pub fn run(args: &[String]) -> u8 {
60    let opts = match clap_shim::parse::<VerifyProofOpts>("mkit verify-proof", args) {
61        Ok(o) => o,
62        Err(code) => return code,
63    };
64    let json = matches!(opts.format, VerifyProofFormat::Json);
65    let commit_id = match from_hex(&opts.commit_id) {
66        Ok(h) => h,
67        Err(e) => {
68            return emit_err(
69                &format!("commit-id must be 64 hex characters: {e}"),
70                exit::DATAERR,
71            );
72        }
73    };
74    let bundle = match read_bundle(&opts.bundle) {
75        Ok(b) => b,
76        Err((msg, code)) => return emit_err(&msg, code),
77    };
78    let disclosed = match verify_disclosure(&commit_id, &bundle) {
79        Ok(d) => d,
80        Err(e) => {
81            emit_human(&format!("bad: {e}"), json);
82            return exit::DATAERR;
83        }
84    };
85    if let Some(expect) = opts.expect_path.as_deref() {
86        let got = authenticated_path(&disclosed.path);
87        let want = display_path(Some(expect));
88        if !paths_match(&got, &want) {
89            emit_human(
90                &format!("bad: authenticated path '{got}' does not match --expect-path '{want}'"),
91                json,
92            );
93            return exit::DATAERR;
94        }
95    }
96
97    let want_trust = opts.trusted || opts.trust_roots.is_some();
98    let mut signer_trusted: Option<bool> = None;
99    let mut trusted_keyid: Option<String> = None;
100    if want_trust {
101        let cwd = match std::env::current_dir() {
102            Ok(p) => p,
103            Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
104        };
105        let layout = match super::resolve_layout(&cwd) {
106            Ok(layout) => layout,
107            Err(code) => return code,
108        };
109        let trust_path = opts
110            .trust_roots
111            .as_deref()
112            .map_or_else(trust_roots::default_trust_roots_path, PathBuf::from);
113        if let Err(code) = trust_roots::warn_if_unsafe_trust_roots(
114            &trust_path,
115            layout.common_dir(),
116            opts.trust_roots.is_some(),
117        ) {
118            return code;
119        }
120        trust_roots::note_if_missing(&trust_path);
121        let entries = match trust_roots::load_entries(&trust_path) {
122            Ok(e) => e,
123            Err((msg, code)) => return emit_err(&msg, code),
124        };
125        if let Some(keyid) = trust_roots::find_ed25519_signer(&entries, &disclosed.signer) {
126            signer_trusted = Some(true);
127            trusted_keyid = Some(trust_roots::short_keyid(keyid));
128        } else {
129            signer_trusted = Some(false);
130        }
131        if !disclosed.signature_valid {
132            emit_result(
133                &disclosed,
134                signer_trusted.as_ref(),
135                trusted_keyid.as_deref(),
136                json,
137            );
138            return exit::DATAERR;
139        }
140        if signer_trusted == Some(false) {
141            emit_result(
142                &disclosed,
143                signer_trusted.as_ref(),
144                trusted_keyid.as_deref(),
145                json,
146            );
147            return exit::DATAERR;
148        }
149    }
150
151    if let Some(path) = opts.payload_out.as_deref() {
152        let bytes = payload_bytes(&disclosed.payload);
153        if let Err(e) = std::fs::write(path, bytes) {
154            return emit_err(&format!("write {}: {e}", path.display()), exit::CANTCREAT);
155        }
156    }
157
158    emit_result(
159        &disclosed,
160        signer_trusted.as_ref(),
161        trusted_keyid.as_deref(),
162        json,
163    );
164    if want_trust && (signer_trusted != Some(true) || !disclosed.signature_valid) {
165        exit::DATAERR
166    } else {
167        exit::OK
168    }
169}
170
171fn read_bundle(spec: &str) -> Result<Vec<u8>, (String, u8)> {
172    if spec == "-" {
173        let mut buf = Vec::new();
174        io::stdin()
175            .take(MAX_BUNDLE_BYTES as u64 + 1)
176            .read_to_end(&mut buf)
177            .map_err(|e| (format!("read stdin: {e}"), exit::NOINPUT))?;
178        if buf.len() > MAX_BUNDLE_BYTES {
179            return Err((
180                format!("disclosure bundle exceeds the {MAX_BUNDLE_BYTES} byte cap"),
181                exit::DATAERR,
182            ));
183        }
184        return Ok(buf);
185    }
186    std::fs::read(spec).map_err(|e| (format!("read {spec}: {e}"), exit::NOINPUT))
187}
188
189fn emit_result(
190    d: &Disclosed,
191    signer_trusted: Option<&bool>,
192    trusted_keyid: Option<&str>,
193    json: bool,
194) {
195    let failed =
196        matches!(signer_trusted, Some(false)) || (signer_trusted.is_some() && !d.signature_valid);
197    if json {
198        emit_json(d, signer_trusted.copied());
199        emit_human(&human_line(d, trusted_keyid, failed), true);
200    } else {
201        emit_human(&human_line(d, trusted_keyid, failed), false);
202    }
203}
204
205fn emit_human(line: &str, to_stderr: bool) {
206    if to_stderr {
207        let mut stderr = io::stderr().lock();
208        let _ = writeln!(stderr, "{line}");
209    } else {
210        let mut stdout = io::stdout().lock();
211        let _ = writeln!(stdout, "{line}");
212    }
213}
214
215fn human_line(d: &Disclosed, trusted_keyid: Option<&str>, failed: bool) -> String {
216    let kind = payload_kind(&d.payload);
217    let path = authenticated_path(&d.path);
218    let short = format::short_hash(&d.commit_id, format::SUMMARY_ABBREV);
219    let nbytes = payload_bytes(&d.payload).len();
220    let keyid = trusted_keyid.map_or_else(
221        || {
222            let hex = to_hex_bytes(&d.signer);
223            if hex.len() > 16 {
224                format!("{}…", &hex[..16])
225            } else {
226                hex
227            }
228        },
229        ToOwned::to_owned,
230    );
231    let sig = if d.signature_valid {
232        "valid signature"
233    } else {
234        "INVALID signature"
235    };
236    let trust = if trusted_keyid.is_some() {
237        ", signer trusted"
238    } else {
239        ""
240    };
241    if failed && !d.signature_valid {
242        format!("bad: {kind} {path} @ {short}, {nbytes} B, signer {keyid} ({sig}){trust}")
243    } else if failed {
244        format!(
245            "bad: signature valid, but signer {} is not in the trust-roots registry",
246            to_hex_bytes(&d.signer)
247        )
248    } else {
249        format!("ok: {kind} {path} @ {short}, {nbytes} B, signer {keyid} ({sig}){trust}")
250    }
251}
252
253fn emit_json(d: &Disclosed, signer_trusted: Option<bool>) {
254    let mut top = JsonObject::new();
255    top.field_hash("commit_id", &d.commit_id)
256        .field_hash("tree_hash", &d.tree_hash)
257        .field_raw("path", &path_json(&d.path))
258        .field_hash("leaf_id", &d.leaf_id)
259        .field_str("signer", &to_hex_bytes(&d.signer))
260        .field_bool("signature_valid", d.signature_valid)
261        .field_raw("payload", &payload_json(&d.payload))
262        .field_raw("step_inner_roots", &hashes_json(&d.step_inner_roots))
263        .field_opt_hash("chunk_inner_root", d.chunk_inner_root.as_ref());
264    match signer_trusted {
265        Some(v) => {
266            top.field_bool("signer_trusted", v);
267        }
268        None => {
269            top.field_raw("signer_trusted", "null");
270        }
271    }
272    let mut stdout = io::stdout().lock();
273    let _ = writeln!(stdout, "{}", top.finish());
274}
275
276fn hashes_json(hashes: &[mkit_core::hash::Hash]) -> String {
277    let items: Vec<String> = hashes
278        .iter()
279        .map(|h| format!("\"{}\"", to_hex(h)))
280        .collect();
281    format!("[{}]", items.join(","))
282}
283
284fn path_json(path: &[(Vec<u8>, EntryMode)]) -> String {
285    let mut items = Vec::with_capacity(path.len());
286    for (name, mode) in path {
287        let mut obj = JsonObject::new();
288        match std::str::from_utf8(name) {
289            Ok(s) => {
290                obj.field_str("name", s);
291            }
292            Err(_) => {
293                obj.field_raw("name", "null");
294            }
295        }
296        obj.field_str("name_hex", &to_hex_bytes(name))
297            .field_str("mode", mode_str(*mode));
298        items.push(obj.finish());
299    }
300    format!("[{}]", items.join(","))
301}
302
303fn payload_json(payload: &DisclosedPayload) -> String {
304    let bytes = payload_bytes(payload);
305    let bytes_len = bytes.len() as u64;
306    let bytes_blake3 = to_hex(&hash(bytes));
307    let mut obj = JsonObject::new();
308    match payload {
309        DisclosedPayload::Object { .. } => {
310            obj.field_str("kind", "object")
311                .field_u64("bytes_len", bytes_len)
312                .field_str("bytes_blake3", &bytes_blake3);
313        }
314        DisclosedPayload::Chunk {
315            total_size,
316            chunk_size,
317            index,
318            ..
319        } => {
320            obj.field_str("kind", "chunk")
321                .field_u64("bytes_len", bytes_len)
322                .field_str("bytes_blake3", &bytes_blake3)
323                .field_u64("index", u64::from(*index))
324                .field_u64("total_size", *total_size)
325                .field_u64("chunk_size", u64::from(*chunk_size));
326        }
327        DisclosedPayload::Range {
328            blob_id,
329            chunk,
330            offset_in_blob,
331            absolute_offset,
332            ..
333        } => {
334            obj.field_str("kind", "range")
335                .field_u64("bytes_len", bytes_len)
336                .field_str("bytes_blake3", &bytes_blake3)
337                .field_hash("blob_id", blob_id);
338            match chunk {
339                None => {
340                    obj.field_raw("chunk", "null");
341                }
342                Some((index, total_size, chunk_size)) => {
343                    let mut c = JsonObject::new();
344                    c.field_u64("index", u64::from(*index))
345                        .field_u64("total_size", *total_size)
346                        .field_u64("chunk_size", u64::from(*chunk_size));
347                    obj.field_raw("chunk", &c.finish());
348                }
349            }
350            obj.field_u64("offset_in_blob", *offset_in_blob);
351            match absolute_offset {
352                Some(off) => {
353                    obj.field_u64("absolute_offset", *off);
354                }
355                None => {
356                    obj.field_raw("absolute_offset", "null");
357                }
358            }
359        }
360    }
361    obj.finish()
362}
363
364fn payload_bytes(payload: &DisclosedPayload) -> &[u8] {
365    match payload {
366        DisclosedPayload::Object { bytes }
367        | DisclosedPayload::Chunk { bytes, .. }
368        | DisclosedPayload::Range { bytes, .. } => bytes,
369    }
370}
371
372fn payload_kind(payload: &DisclosedPayload) -> &'static str {
373    match payload {
374        DisclosedPayload::Object { .. } => "object",
375        DisclosedPayload::Chunk { .. } => "chunk",
376        DisclosedPayload::Range { .. } => "range",
377    }
378}
379
380fn mode_str(mode: EntryMode) -> &'static str {
381    match mode {
382        EntryMode::Blob => "blob",
383        EntryMode::Tree => "tree",
384        EntryMode::Symlink => "symlink",
385        EntryMode::Executable => "exec",
386    }
387}
388
389fn authenticated_path(path: &[(Vec<u8>, EntryMode)]) -> String {
390    if path.is_empty() {
391        return "/".into();
392    }
393    path.iter()
394        .map(|(n, _)| String::from_utf8_lossy(n).into_owned())
395        .collect::<Vec<_>>()
396        .join("/")
397}
398
399fn paths_match(got: &str, want: &str) -> bool {
400    normalize_path(got) == normalize_path(want)
401}
402
403fn normalize_path(p: &str) -> String {
404    let t = p.trim_matches('/');
405    if t.is_empty() {
406        String::new()
407    } else {
408        t.to_owned()
409    }
410}
411
412use super::error as emit_err;
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    fn parse_args(args: &[String]) -> Result<VerifyProofOpts, clap::Error> {
419        let mut full: Vec<String> = vec!["mkit verify-proof".into()];
420        full.extend_from_slice(args);
421        VerifyProofOpts::try_parse_from(full)
422    }
423
424    #[test]
425    fn parse_positional() {
426        let p = parse_args(&["aa".repeat(32), "bundle.bin".into()]).unwrap();
427        assert_eq!(p.bundle, "bundle.bin");
428        assert!(p.expect_path.is_none());
429        assert!(!p.trusted);
430    }
431
432    #[test]
433    fn paths_match_root_aliases() {
434        assert!(paths_match("/", ""));
435        assert!(paths_match("/", "/"));
436        assert!(paths_match("src/a.txt", "/src/a.txt"));
437        assert!(!paths_match("a.txt", "b.txt"));
438    }
439}