Skip to main content

vgi_core/
commit.rs

1//! Git commit-object handling for signature verification.
2//!
3//! Git signs the commit object with its `gpgsig` header removed;
4//! [`split_signed_commit`] reconstructs the exact signed bytes and recovers the
5//! armored signature. [`normalize_sshsig_armor`] re-wraps an sshsig body to the
6//! 70-column width strict PEM parsers require.
7
8use anyhow::{Context, Result, bail};
9
10/// Re-wrap an sshsig armor's base64 body at 70 columns.
11///
12/// OpenSSH's own base64 reader accepts any line width, but the strict PEM
13/// parser underneath `SshSig::from_pem` requires exactly the 70-column
14/// wrapping ssh-keygen emits. Signatures created by did-git-sign before it
15/// matched ssh-keygen's width (76 columns) live on in git history, so the
16/// armor is normalized rather than trusted to be canonical.
17pub fn normalize_sshsig_armor(pem: &str) -> String {
18    let body: String = pem
19        .lines()
20        .filter(|line| !line.starts_with("-----"))
21        .map(str::trim)
22        .collect();
23    let mut normalized = String::from("-----BEGIN SSH SIGNATURE-----\n");
24    for chunk in body.as_bytes().chunks(70) {
25        // Chunks of an ASCII base64 string are always valid UTF-8.
26        normalized.push_str(&String::from_utf8_lossy(chunk));
27        normalized.push('\n');
28    }
29    normalized.push_str("-----END SSH SIGNATURE-----\n");
30    normalized
31}
32
33/// Split a raw commit object into (payload-as-signed, armored signature).
34///
35/// Git signs the commit object with the `gpgsig` header removed; the header's
36/// value spans continuation lines (each prefixed with one space). Returns
37/// `Ok(None)` for an unsigned commit.
38pub fn split_signed_commit(raw: &[u8]) -> Result<Option<(Vec<u8>, String)>> {
39    let text = std::str::from_utf8(raw).context("commit object is not UTF-8")?;
40    let Some((headers, body)) = text.split_once("\n\n") else {
41        bail!("malformed commit object: no header/body separator");
42    };
43
44    let mut kept_headers: Vec<&str> = Vec::new();
45    let mut signature_lines: Vec<&str> = Vec::new();
46    let mut in_gpgsig = false;
47    for line in headers.split('\n') {
48        if let Some(first) = line.strip_prefix("gpgsig ") {
49            in_gpgsig = true;
50            signature_lines.push(first);
51        } else if in_gpgsig && let Some(continuation) = line.strip_prefix(' ') {
52            signature_lines.push(continuation);
53        } else {
54            in_gpgsig = false;
55            kept_headers.push(line);
56        }
57    }
58
59    if signature_lines.is_empty() {
60        return Ok(None);
61    }
62
63    let mut payload = kept_headers.join("\n").into_bytes();
64    payload.extend_from_slice(b"\n\n");
65    payload.extend_from_slice(body.as_bytes());
66
67    let mut pem = signature_lines.join("\n");
68    pem.push('\n');
69    Ok(Some((payload, pem)))
70}