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/// The signer DID a commit claims, checking the `Signed-by-DID:` trailer
121/// first, then falling back to the committer email for legacy commits.
122///
123/// The trailer is the canonical location for new commits (it lets
124/// `user.email` be a normal email for git-host attribution). Old commits
125/// that carried the DID in the committer email still verify via the
126/// fallback.
127#[must_use]
128pub fn signer_did(commit: &[u8]) -> Option<String> {
129    trailer_did(commit).or_else(|| committer_did(commit))
130}
131
132/// Return both explicit identity claims when the final `Signed-by-DID:`
133/// trailer and legacy DID committer identity disagree.
134#[must_use]
135pub fn conflicting_signer_dids(commit: &[u8]) -> Option<(String, String)> {
136    let trailer = trailer_did(commit)?;
137    let committer = committer_did(commit)?;
138    (trailer != committer).then_some((trailer, committer))
139}
140
141/// Extract a bare DID from a `Signed-by-DID:` trailer in the commit body's
142/// final trailer block.
143fn trailer_did(commit: &[u8]) -> Option<String> {
144    let text = std::str::from_utf8(commit).ok()?;
145    let (_, body) = text.split_once("\n\n")?;
146
147    let mut lines: Vec<&str> = body.lines().collect();
148    while lines.last().is_some_and(|line| line.trim().is_empty()) {
149        lines.pop();
150    }
151
152    let mut trailer_start = lines.len();
153    while trailer_start > 0 && is_trailer_line(lines[trailer_start - 1]) {
154        trailer_start -= 1;
155    }
156    if trailer_start == lines.len() {
157        return None;
158    }
159
160    for line in lines[trailer_start..].iter().rev() {
161        if let Some(value) = line.strip_prefix("Signed-by-DID:") {
162            let value = value.trim();
163            if value.starts_with("did:") {
164                return Some(
165                    value
166                        .split(['#', '?', '/'])
167                        .next()
168                        .unwrap_or(value)
169                        .to_string(),
170                );
171            }
172        }
173    }
174    None
175}
176
177fn is_trailer_line(line: &str) -> bool {
178    let Some((key, _)) = line.split_once(':') else {
179        return false;
180    };
181    !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
182}
183
184#[cfg(test)]
185mod tests {
186    #![allow(clippy::unwrap_used)]
187
188    use super::*;
189
190    fn commit_with_committer(committer: &str) -> String {
191        format!(
192            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
193             author A U Thor <a@example.com> 1700000000 +0000\n\
194             committer {committer} 1700000000 +0000\n\
195             \n\
196             a message\n"
197        )
198    }
199
200    #[test]
201    fn a_did_committer_yields_the_bare_did() {
202        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
203        assert_eq!(
204            committer_did(commit.as_bytes()).unwrap(),
205            "did:webvh:QmAbc:example.com",
206            "the fragment names the key, not the identity the registry knows"
207        );
208    }
209
210    #[test]
211    fn a_did_without_a_fragment_survives_intact() {
212        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com>");
213        assert_eq!(
214            committer_did(commit.as_bytes()).unwrap(),
215            "did:webvh:QmAbc:example.com"
216        );
217    }
218
219    #[test]
220    fn a_plain_email_committer_claims_no_did() {
221        let commit = commit_with_committer("Alice <alice@example.com>");
222        assert!(committer_did(commit.as_bytes()).is_none());
223        assert_eq!(
224            committer_identity(commit.as_bytes()).unwrap(),
225            "alice@example.com",
226            "the identity is still reported, so the failure can name it"
227        );
228    }
229
230    #[test]
231    fn a_body_line_cannot_impersonate_the_committer_header() {
232        // The header block ends at the first blank line; everything after it
233        // is the message, where an author controls every byte.
234        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
235             author A U Thor <a@example.com> 1700000000 +0000\n\
236             committer A U Thor <alice@example.com> 1700000000 +0000\n\
237             \n\
238             committer Evil <did:webvh:QmEvil:attacker.example> 1700000000 +0000\n";
239        assert!(
240            committer_did(commit.as_bytes()).is_none(),
241            "a DID in the message body must not be read as the committer"
242        );
243    }
244
245    #[test]
246    fn a_display_name_containing_an_angle_bracket_does_not_truncate() {
247        let commit = commit_with_committer("A <script> Thor <did:webvh:QmAbc:example.com#key-1>");
248        assert_eq!(
249            committer_did(commit.as_bytes()).unwrap(),
250            "did:webvh:QmAbc:example.com"
251        );
252    }
253
254    #[test]
255    fn a_signed_commits_payload_still_exposes_the_committer() {
256        // The committer header is a kept header, so it survives the gpgsig
257        // strip and is covered by the signature.
258        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
259        let (headers, body) = commit.split_once("\n\n").unwrap();
260        let signed = format!(
261            "{headers}\ngpgsig -----BEGIN SSH SIGNATURE-----\n \
262             AAAA\n -----END SSH SIGNATURE-----\n\n{body}"
263        );
264        let (payload, _) = split_signed_commit(signed.as_bytes()).unwrap().unwrap();
265        assert_eq!(
266            committer_did(&payload).unwrap(),
267            "did:webvh:QmAbc:example.com"
268        );
269    }
270
271    fn commit_with_trailer(committer: &str, trailer: &str) -> String {
272        format!(
273            "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
274             author A U Thor <a@example.com> 1700000000 +0000\n\
275             committer {committer} 1700000000 +0000\n\
276             \n\
277             a message\n\
278             \n\
279             {trailer}\n"
280        )
281    }
282
283    #[test]
284    fn signer_did_prefers_trailer_over_committer() {
285        let commit = commit_with_trailer(
286            "Alice <did:webvh:QmOld:old.example#key-0>",
287            "Signed-by-DID: did:webvh:QmNew:new.example#key-0",
288        );
289        assert_eq!(
290            signer_did(commit.as_bytes()).unwrap(),
291            "did:webvh:QmNew:new.example",
292            "trailer must take precedence over committer email"
293        );
294    }
295
296    #[test]
297    fn signer_did_falls_back_to_committer_for_legacy_commits() {
298        let commit = commit_with_committer("Alice <did:webvh:QmAbc:example.com#key-0>");
299        assert_eq!(
300            signer_did(commit.as_bytes()).unwrap(),
301            "did:webvh:QmAbc:example.com",
302            "legacy commits with DID in committer email must still work"
303        );
304    }
305
306    #[test]
307    fn signer_did_reads_trailer_with_normal_email_committer() {
308        let commit = commit_with_trailer(
309            "Alice <alice@example.com>",
310            "Signed-by-DID: did:webvh:QmAbc:example.com#key-0",
311        );
312        assert_eq!(
313            signer_did(commit.as_bytes()).unwrap(),
314            "did:webvh:QmAbc:example.com",
315        );
316    }
317
318    #[test]
319    fn signer_did_returns_none_without_did_anywhere() {
320        let commit = commit_with_committer("Alice <alice@example.com>");
321        assert!(signer_did(commit.as_bytes()).is_none());
322    }
323
324    #[test]
325    fn trailer_strips_fragment() {
326        let commit = commit_with_trailer(
327            "Alice <alice@example.com>",
328            "Signed-by-DID: did:webvh:QmAbc:example.com#key-1",
329        );
330        assert_eq!(
331            signer_did(commit.as_bytes()).unwrap(),
332            "did:webvh:QmAbc:example.com",
333        );
334    }
335
336    #[test]
337    fn trailer_ignores_non_did_values() {
338        let commit = commit_with_trailer("Alice <alice@example.com>", "Signed-by-DID: not-a-did");
339        assert!(signer_did(commit.as_bytes()).is_none());
340    }
341
342    #[test]
343    fn signer_did_ignores_body_line_outside_final_trailer_block() {
344        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
345             author A U Thor <a@example.com> 1700000000 +0000\n\
346             committer Alice <alice@example.com> 1700000000 +0000\n\
347             \n\
348             This line only discusses a trailer.\n\
349             Signed-by-DID: did:webvh:QmBody:example.com#key-0\n\
350             \n\
351             final prose, not a trailer block\n";
352        assert!(signer_did(commit.as_bytes()).is_none());
353    }
354
355    #[test]
356    fn signer_did_reads_final_trailer_block_only() {
357        let commit = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
358             author A U Thor <a@example.com> 1700000000 +0000\n\
359             committer Alice <alice@example.com> 1700000000 +0000\n\
360             \n\
361             Signed-by-DID: did:webvh:QmBody:ignored.example#key-0\n\
362             \n\
363             body text\n\
364             \n\
365             Signed-off-by: Alice <alice@example.com>\n\
366             Signed-by-DID: did:webvh:QmTrailer:example.com#key-0\n";
367        assert_eq!(
368            signer_did(commit.as_bytes()).unwrap(),
369            "did:webvh:QmTrailer:example.com"
370        );
371    }
372
373    #[test]
374    fn conflicting_signer_dids_reports_trailer_and_committer_disagreement() {
375        let commit = commit_with_trailer(
376            "Alice <did:webvh:QmCommitter:example.com#key-0>",
377            "Signed-by-DID: did:webvh:QmTrailer:example.com#key-0",
378        );
379        assert_eq!(
380            conflicting_signer_dids(commit.as_bytes()).unwrap(),
381            (
382                "did:webvh:QmTrailer:example.com".to_string(),
383                "did:webvh:QmCommitter:example.com".to_string(),
384            )
385        );
386    }
387}