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. [`committer_did`] reads the
7//! signer identity a commit claims on its `committer` header.
8
9use anyhow::{Context, Result, bail};
10
11/// Re-wrap an sshsig armor's base64 body at 70 columns.
12///
13/// OpenSSH's own base64 reader accepts any line width, but the strict PEM
14/// parser underneath `SshSig::from_pem` requires exactly the 70-column
15/// wrapping ssh-keygen emits. Signatures created by did-git-sign before it
16/// matched ssh-keygen's width (76 columns) live on in git history, so the
17/// armor is normalized rather than trusted to be canonical.
18pub fn normalize_sshsig_armor(pem: &str) -> String {
19    let body: String = pem
20        .lines()
21        .filter(|line| !line.starts_with("-----"))
22        .map(str::trim)
23        .collect();
24    let mut normalized = String::from("-----BEGIN SSH SIGNATURE-----\n");
25    for chunk in body.as_bytes().chunks(70) {
26        // Chunks of an ASCII base64 string are always valid UTF-8.
27        normalized.push_str(&String::from_utf8_lossy(chunk));
28        normalized.push('\n');
29    }
30    normalized.push_str("-----END SSH SIGNATURE-----\n");
31    normalized
32}
33
34/// Split a raw commit object into (payload-as-signed, armored signature).
35///
36/// Git signs the commit object with the `gpgsig` header removed; the header's
37/// value spans continuation lines (each prefixed with one space). Returns
38/// `Ok(None)` for an unsigned commit.
39pub fn split_signed_commit(raw: &[u8]) -> Result<Option<(Vec<u8>, String)>> {
40    let text = std::str::from_utf8(raw).context("commit object is not UTF-8")?;
41    let Some((headers, body)) = text.split_once("\n\n") else {
42        bail!("malformed commit object: no header/body separator");
43    };
44
45    let mut kept_headers: Vec<&str> = Vec::new();
46    let mut signature_lines: Vec<&str> = Vec::new();
47    let mut in_gpgsig = false;
48    for line in headers.split('\n') {
49        if let Some(first) = line.strip_prefix("gpgsig ") {
50            in_gpgsig = true;
51            signature_lines.push(first);
52        } else if in_gpgsig && let Some(continuation) = line.strip_prefix(' ') {
53            signature_lines.push(continuation);
54        } else {
55            in_gpgsig = false;
56            kept_headers.push(line);
57        }
58    }
59
60    if signature_lines.is_empty() {
61        return Ok(None);
62    }
63
64    let mut payload = kept_headers.join("\n").into_bytes();
65    payload.extend_from_slice(b"\n\n");
66    payload.extend_from_slice(body.as_bytes());
67
68    let mut pem = signature_lines.join("\n");
69    pem.push('\n');
70    Ok(Some((payload, pem)))
71}
72
73/// The committer identity: the `<…>` field of the `committer` header.
74///
75/// Read from the header block only, so a body line that happens to begin with
76/// `committer ` cannot be mistaken for the header. Returns `None` for a commit
77/// with no committer header or no angle-bracketed identity.
78#[must_use]
79pub fn committer_identity(commit: &[u8]) -> Option<String> {
80    let text = std::str::from_utf8(commit).ok()?;
81    let headers = text.split_once("\n\n").map_or(text, |(headers, _)| headers);
82    let line = headers
83        .split('\n')
84        .find_map(|line| line.strip_prefix("committer "))?;
85    // `rfind` so a display name containing '<' cannot truncate the identity.
86    let open = line.rfind('<')?;
87    let close = line[open..].find('>')? + open;
88    Some(line[open + 1..close].to_string())
89}
90
91/// The signer DID a commit claims: its committer identity when that is a DID,
92/// reduced to the bare DID.
93///
94/// `did-git-sign` sets `user.email` to the verification-method id it signs
95/// with (`did:webvh:…#key-0`); the fragment names *which* key, while the DID
96/// is the identity to resolve and to ask the registry about, so any
97/// fragment, path or query is stripped.
98///
99/// This is a **claim**, not an authenticated fact — the committer header is
100/// author-controlled text. It is safe to use only as a lookup hint whose
101/// answer is then checked: the DID must publish the key that actually signed,
102/// and the signature must verify over a payload that includes this very
103/// header. A commit claiming a DID it cannot sign for fails both checks.
104#[must_use]
105pub fn committer_did(commit: &[u8]) -> Option<String> {
106    let identity = committer_identity(commit)?;
107    if !identity.starts_with("did:") {
108        return None;
109    }
110    let did = identity
111        .split(['#', '?', '/'])
112        .next()
113        .unwrap_or(identity.as_str());
114    if did.is_empty() {
115        return None;
116    }
117    Some(did.to_string())
118}
119
120#[cfg(test)]
121mod tests {
122    #![allow(clippy::unwrap_used)]
123
124    use super::*;
125
126    fn commit_with_committer(committer: &str) -> String {
127        format!(
128            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
129             author A U Thor <a@example.com> 1700000000 +0000\n\
130             committer {committer} 1700000000 +0000\n\
131             \n\
132             a message\n"
133        )
134    }
135
136    #[test]
137    fn a_did_committer_yields_the_bare_did() {
138        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
139        assert_eq!(
140            committer_did(commit.as_bytes()).unwrap(),
141            "did:webvh:QmAbc:example.com",
142            "the fragment names the key, not the identity the registry knows"
143        );
144    }
145
146    #[test]
147    fn a_did_without_a_fragment_survives_intact() {
148        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com>");
149        assert_eq!(
150            committer_did(commit.as_bytes()).unwrap(),
151            "did:webvh:QmAbc:example.com"
152        );
153    }
154
155    #[test]
156    fn a_plain_email_committer_claims_no_did() {
157        let commit = commit_with_committer("Alice <alice@example.com>");
158        assert!(committer_did(commit.as_bytes()).is_none());
159        assert_eq!(
160            committer_identity(commit.as_bytes()).unwrap(),
161            "alice@example.com",
162            "the identity is still reported, so the failure can name it"
163        );
164    }
165
166    #[test]
167    fn a_body_line_cannot_impersonate_the_committer_header() {
168        // The header block ends at the first blank line; everything after it
169        // is the message, where an author controls every byte.
170        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
171             author A U Thor <a@example.com> 1700000000 +0000\n\
172             committer A U Thor <alice@example.com> 1700000000 +0000\n\
173             \n\
174             committer Evil <did:webvh:QmEvil:attacker.example> 1700000000 +0000\n";
175        assert!(
176            committer_did(commit.as_bytes()).is_none(),
177            "a DID in the message body must not be read as the committer"
178        );
179    }
180
181    #[test]
182    fn a_display_name_containing_an_angle_bracket_does_not_truncate() {
183        let commit = commit_with_committer("A <script> Thor <did:webvh:QmAbc:example.com#key-1>");
184        assert_eq!(
185            committer_did(commit.as_bytes()).unwrap(),
186            "did:webvh:QmAbc:example.com"
187        );
188    }
189
190    #[test]
191    fn a_signed_commits_payload_still_exposes_the_committer() {
192        // The committer header is a kept header, so it survives the gpgsig
193        // strip and is covered by the signature.
194        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
195        let (headers, body) = commit.split_once("\n\n").unwrap();
196        let signed = format!(
197            "{headers}\ngpgsig -----BEGIN SSH SIGNATURE-----\n \
198             AAAA\n -----END SSH SIGNATURE-----\n\n{body}"
199        );
200        let (payload, _) = split_signed_commit(signed.as_bytes()).unwrap().unwrap();
201        assert_eq!(
202            committer_did(&payload).unwrap(),
203            "did:webvh:QmAbc:example.com"
204        );
205    }
206}