Skip to main content

rns_git/
commitsigs.rs

1use std::fs;
2use std::io::{self, Read};
3use std::path::PathBuf;
4
5use rns_core::msgpack::{self, Value};
6use rns_crypto::identity::Identity;
7use rns_crypto::sha256::sha256;
8
9use crate::{Error, Result};
10
11const SSHSIG_MAGIC: &[u8] = b"SSHSIG";
12const SSHSIG_VERSION: u32 = 1;
13const NAMESPACE_GIT: &[u8] = b"git";
14const HASH_ALGORITHM: &[u8] = b"sha256";
15const SIG_LEN: usize = 64;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SignatureStatus {
19    pub signed: bool,
20    pub valid: bool,
21    pub signer_hash: Option<[u8; 16]>,
22    pub author_match: bool,
23    pub message: String,
24}
25
26#[derive(Debug, Default)]
27struct Options {
28    op: Option<String>,
29    namespace: String,
30    keyfile: Option<PathBuf>,
31    sigfile: Option<PathBuf>,
32    principal: Option<String>,
33    file: Option<PathBuf>,
34}
35
36pub fn main<I>(args: I) -> Result<()>
37where
38    I: IntoIterator<Item = String>,
39{
40    let options = parse_options(args)?;
41    match options.op.as_deref() {
42        Some("sign") => {
43            let keyfile = options
44                .keyfile
45                .as_deref()
46                .ok_or_else(|| Error::msg("missing identity file"))?;
47            let identity = rns_net::storage::load_identity(keyfile)?;
48            let (message, output_path) = if let Some(file) = options.file.as_deref() {
49                (
50                    fs::read(file)?,
51                    Some(PathBuf::from(format!("{}.sig", file.display()))),
52                )
53            } else {
54                let mut message = Vec::new();
55                io::stdin().read_to_end(&mut message)?;
56                (message, None)
57            };
58            let armored = sign_message(&identity, &message)?;
59            if let Some(path) = output_path {
60                fs::write(path, armored)?;
61            } else {
62                print!("{armored}");
63            }
64        }
65        Some("find-principals") => {
66            let sigfile = options
67                .sigfile
68                .as_deref()
69                .ok_or_else(|| Error::msg("missing signature file"))?;
70            let armored = fs::read_to_string(sigfile)?;
71            let signer = find_principal(&armored)?;
72            println!("{}", crate::util::hex(&signer));
73        }
74        Some("check-novalidate") => {
75            let sigfile = options
76                .sigfile
77                .as_deref()
78                .ok_or_else(|| Error::msg("missing signature file"))?;
79            let armored = fs::read_to_string(sigfile)?;
80            if !check_novalidate(&armored) {
81                return Err(Error::msg(
82                    "signature is not a valid Reticulum Git signature",
83                ));
84            }
85        }
86        Some("verify") => {
87            let sigfile = options
88                .sigfile
89                .as_deref()
90                .ok_or_else(|| Error::msg("missing signature file"))?;
91            let armored = fs::read_to_string(sigfile)?;
92            let mut message = Vec::new();
93            io::stdin().read_to_end(&mut message)?;
94            let status = verify_message_signature(&armored, &message, options.principal.as_deref());
95            if status.valid && status.author_match {
96                println!("{}", status.message);
97            } else {
98                return Err(Error::msg(status.message));
99            }
100        }
101        _ => return Err(Error::msg(usage())),
102    }
103    if options.namespace.as_bytes() != NAMESPACE_GIT {
104        return Err(Error::msg("only git namespace is supported"));
105    }
106    Ok(())
107}
108
109fn parse_options<I>(args: I) -> Result<Options>
110where
111    I: IntoIterator<Item = String>,
112{
113    let mut options = Options {
114        namespace: "git".into(),
115        ..Options::default()
116    };
117    let mut iter = args.into_iter();
118    while let Some(arg) = iter.next() {
119        match arg.as_str() {
120            "-Y" => options.op = iter.next(),
121            "-n" => {
122                options.namespace = iter.next().ok_or_else(|| Error::msg("missing namespace"))?;
123            }
124            "-f" => {
125                options.keyfile = Some(PathBuf::from(
126                    iter.next()
127                        .ok_or_else(|| Error::msg("missing key/signers file"))?,
128                ));
129            }
130            "-s" => {
131                options.sigfile = Some(PathBuf::from(
132                    iter.next()
133                        .ok_or_else(|| Error::msg("missing signature file"))?,
134                ));
135            }
136            "-I" => {
137                options.principal =
138                    Some(iter.next().ok_or_else(|| Error::msg("missing principal"))?);
139            }
140            "-O" => {
141                let _ = iter.next();
142            }
143            other if other.starts_with("-O") => {}
144            "-h" | "--help" => return Err(Error::msg(usage())),
145            other if other.starts_with('-') => {
146                return Err(Error::msg(format!("unknown option {other}")));
147            }
148            other => options.file = Some(PathBuf::from(other)),
149        }
150    }
151    Ok(options)
152}
153
154fn usage() -> &'static str {
155    "usage: rngcs -Y <sign|find-principals|check-novalidate|verify> [-f KEY] [-s SIG] [-I PRINCIPAL] [FILE]"
156}
157
158pub fn sign_message(identity: &Identity, message: &[u8]) -> Result<String> {
159    let rsg = create_rsg(identity, message)?;
160    let ssh_pubkey = git_ssh_public_key(identity)?;
161    let blob = create_ssh_signature(&ssh_pubkey, NAMESPACE_GIT, b"", HASH_ALGORITHM, &rsg);
162    Ok(armor_ssh_signature(&blob))
163}
164
165pub fn find_principal(armored: &str) -> Result<[u8; 16]> {
166    let sig = parse_armored_ssh_signature(armored)?;
167    if sig.namespace != NAMESPACE_GIT {
168        return Err(Error::msg("invalid signature namespace"));
169    }
170    let envelope = rsg_envelope(&sig.signature_data)?;
171    let meta = envelope
172        .map_get("meta")
173        .ok_or_else(|| Error::msg("RSG is missing metadata"))?;
174    let signer = meta
175        .map_get("signer")
176        .and_then(Value::as_bin)
177        .ok_or_else(|| Error::msg("RSG is missing signer"))?;
178    signer
179        .try_into()
180        .map_err(|_| Error::msg("invalid signer hash length"))
181}
182
183pub fn check_novalidate(armored: &str) -> bool {
184    parse_armored_ssh_signature(armored)
185        .and_then(|sig| {
186            if sig.namespace != NAMESPACE_GIT {
187                return Err(Error::msg("invalid namespace"));
188            }
189            let _ = rsg_envelope(&sig.signature_data)?;
190            Ok(())
191        })
192        .is_ok()
193}
194
195pub fn verify_message_signature(
196    armored: &str,
197    message: &[u8],
198    principal: Option<&str>,
199) -> SignatureStatus {
200    match verify_message_signature_inner(armored, message, principal) {
201        Ok(status) => status,
202        Err(err) => SignatureStatus {
203            signed: true,
204            valid: false,
205            signer_hash: None,
206            author_match: false,
207            message: err.to_string(),
208        },
209    }
210}
211
212fn verify_message_signature_inner(
213    armored: &str,
214    message: &[u8],
215    principal: Option<&str>,
216) -> Result<SignatureStatus> {
217    let sig = parse_armored_ssh_signature(armored)?;
218    if sig.namespace != NAMESPACE_GIT {
219        return Err(Error::msg("invalid commit signature namespace"));
220    }
221    let signer_hash = validate_rsg(&sig.signature_data, message)?;
222    if let Some(principal) = principal {
223        if principal != crate::util::hex(&signer_hash) {
224            return Ok(SignatureStatus {
225                signed: true,
226                valid: true,
227                signer_hash: Some(signer_hash),
228                author_match: false,
229                message: "principal mismatch".into(),
230            });
231        }
232    }
233    let (required_signer, object_kind) = signature_required_signer(message)
234        .ok_or_else(|| Error::msg("could not determine object signer"))?;
235    if required_signer != crate::util::hex(&signer_hash) {
236        return Ok(SignatureStatus {
237            signed: true,
238            valid: true,
239            signer_hash: Some(signer_hash),
240            author_match: false,
241            message: format!(
242                "{object_kind} not signed by expected identity <{}> (actual signer <{}>)",
243                required_signer,
244                crate::util::hex(&signer_hash)
245            ),
246        });
247    }
248    Ok(SignatureStatus {
249        signed: true,
250        valid: true,
251        signer_hash: Some(signer_hash),
252        author_match: true,
253        message: format!(
254            "Good \"git\" signature for commit, signed with Reticulum Identity key <{}>",
255            crate::util::hex(&signer_hash)
256        ),
257    })
258}
259
260fn signature_required_signer(message: &[u8]) -> Option<(String, &'static str)> {
261    let (tagger, is_tag) = extract_tag_tagger(message);
262    if is_tag {
263        tagger.map(|tagger| (tagger, "tag"))
264    } else {
265        extract_commit_author(message).map(|author| (author, "commit"))
266    }
267}
268
269pub fn extract_commit_author(message: &[u8]) -> Option<String> {
270    for line in message.split(|b| *b == b'\n') {
271        if line.is_empty() {
272            break;
273        }
274        let Some(rest) = line.strip_prefix(b"author ") else {
275            continue;
276        };
277        let start = rest.iter().position(|b| *b == b'<')?;
278        let end = rest.iter().position(|b| *b == b'>')?;
279        if end <= start {
280            return None;
281        }
282        return std::str::from_utf8(&rest[start + 1..end])
283            .ok()
284            .map(str::to_string);
285    }
286    None
287}
288
289pub fn extract_tag_tagger(message: &[u8]) -> (Option<String>, bool) {
290    let mut is_tag = false;
291    for line in message.split(|b| *b == b'\n') {
292        if line.is_empty() {
293            break;
294        }
295        if line.starts_with(b"tag ") {
296            is_tag = true;
297            continue;
298        }
299        let Some(rest) = line.strip_prefix(b"tagger ") else {
300            continue;
301        };
302        let Some(start) = rest.iter().position(|b| *b == b'<') else {
303            return (None, is_tag);
304        };
305        let Some(end) = rest.iter().position(|b| *b == b'>') else {
306            return (None, is_tag);
307        };
308        if end <= start {
309            return (None, is_tag);
310        }
311        return (
312            std::str::from_utf8(&rest[start + 1..end])
313                .ok()
314                .map(str::to_string),
315            is_tag,
316        );
317    }
318    (None, is_tag)
319}
320
321fn create_rsg(identity: &Identity, message: &[u8]) -> Result<Vec<u8>> {
322    let public_key = identity
323        .get_public_key()
324        .ok_or_else(|| Error::msg("identity does not hold a public key"))?;
325    let envelope = Value::Map(vec![
326        (Value::Str("hashtype".into()), Value::Str("sha256".into())),
327        (
328            Value::Str("hash".into()),
329            Value::Bin(sha256(message).to_vec()),
330        ),
331        (
332            Value::Str("meta".into()),
333            Value::Map(vec![
334                (
335                    Value::Str("signer".into()),
336                    Value::Bin(identity.hash().to_vec()),
337                ),
338                (Value::Str("pubkey".into()), Value::Bin(public_key.to_vec())),
339            ]),
340        ),
341    ]);
342    let envelope = msgpack::pack(&envelope);
343    let signature = identity
344        .sign(&envelope)
345        .map_err(|_| Error::msg("identity does not hold a private key"))?;
346    let mut out = Vec::with_capacity(SIG_LEN + envelope.len());
347    out.extend_from_slice(&signature);
348    out.extend_from_slice(&envelope);
349    Ok(out)
350}
351
352fn validate_rsg(rsg: &[u8], message: &[u8]) -> Result<[u8; 16]> {
353    if rsg.len() <= SIG_LEN {
354        return Err(Error::msg("invalid RSG"));
355    }
356    let signature: [u8; SIG_LEN] = rsg[..SIG_LEN].try_into().unwrap();
357    let envelope_bytes = &rsg[SIG_LEN..];
358    let envelope = rsg_envelope(rsg)?;
359    if envelope.map_get("hashtype").and_then(Value::as_str) != Some("sha256") {
360        return Err(Error::msg("unsupported RSG hash type"));
361    }
362    let signed_hash = envelope
363        .map_get("hash")
364        .and_then(Value::as_bin)
365        .ok_or_else(|| Error::msg("RSG is missing hash"))?;
366    if signed_hash != sha256(message).as_slice() {
367        return Err(Error::msg("RSG hash does not match message"));
368    }
369    let meta = envelope
370        .map_get("meta")
371        .ok_or_else(|| Error::msg("RSG is missing metadata"))?;
372    let public_key = meta
373        .map_get("pubkey")
374        .and_then(Value::as_bin)
375        .ok_or_else(|| Error::msg("RSG is missing public key"))?;
376    let public_key: [u8; 64] = public_key
377        .try_into()
378        .map_err(|_| Error::msg("invalid public key length"))?;
379    let identity = Identity::from_public_key(&public_key);
380    let signer_hash = *identity.hash();
381    let meta_signer = meta
382        .map_get("signer")
383        .and_then(Value::as_bin)
384        .ok_or_else(|| Error::msg("RSG is missing signer"))?;
385    if meta_signer != signer_hash {
386        return Err(Error::msg("RSG signer does not match public key"));
387    }
388    if !identity.verify(&signature, envelope_bytes) {
389        return Err(Error::msg("invalid signature"));
390    }
391    Ok(signer_hash)
392}
393
394fn rsg_envelope(rsg: &[u8]) -> Result<Value> {
395    if rsg.len() <= SIG_LEN {
396        return Err(Error::msg("invalid RSG"));
397    }
398    msgpack::unpack_exact(&rsg[SIG_LEN..])
399        .map_err(|e| Error::msg(format!("invalid RSG envelope: {e}")))
400}
401
402fn git_ssh_public_key(identity: &Identity) -> Result<Vec<u8>> {
403    let public_key = identity
404        .get_public_key()
405        .ok_or_else(|| Error::msg("identity does not hold a public key"))?;
406    let mut out = Vec::new();
407    push_ssh_string(&mut out, b"ssh-ed25519");
408    push_ssh_string(&mut out, &public_key[32..]);
409    Ok(out)
410}
411
412pub struct ParsedSshSignature {
413    pub namespace: Vec<u8>,
414    pub signature_data: Vec<u8>,
415}
416
417pub fn parse_armored_ssh_signature(armored: &str) -> Result<ParsedSshSignature> {
418    let blob = unarmor_ssh_signature(armored)?;
419    parse_ssh_signature(&blob)
420}
421
422fn create_ssh_signature(
423    public_key_wire: &[u8],
424    namespace: &[u8],
425    reserved: &[u8],
426    hash_algorithm: &[u8],
427    signature_data: &[u8],
428) -> Vec<u8> {
429    let mut out = Vec::new();
430    out.extend_from_slice(SSHSIG_MAGIC);
431    out.extend_from_slice(&SSHSIG_VERSION.to_be_bytes());
432    push_ssh_string(&mut out, public_key_wire);
433    push_ssh_string(&mut out, namespace);
434    push_ssh_string(&mut out, reserved);
435    push_ssh_string(&mut out, hash_algorithm);
436    push_ssh_string(&mut out, signature_data);
437    out
438}
439
440fn parse_ssh_signature(blob: &[u8]) -> Result<ParsedSshSignature> {
441    let mut offset = 0usize;
442    if !blob.starts_with(SSHSIG_MAGIC) {
443        return Err(Error::msg("invalid SSH signature magic"));
444    }
445    offset += SSHSIG_MAGIC.len();
446    let version = read_u32(blob, &mut offset)?;
447    if version != SSHSIG_VERSION {
448        return Err(Error::msg(format!(
449            "unsupported SSH signature version {version}"
450        )));
451    }
452    let _public_key = read_ssh_string(blob, &mut offset)?;
453    let namespace = read_ssh_string(blob, &mut offset)?;
454    let _reserved = read_ssh_string(blob, &mut offset)?;
455    let hash_algorithm = read_ssh_string(blob, &mut offset)?;
456    if hash_algorithm != HASH_ALGORITHM {
457        return Err(Error::msg("unsupported SSH signature hash algorithm"));
458    }
459    let signature_data = read_ssh_string(blob, &mut offset)?;
460    Ok(ParsedSshSignature {
461        namespace,
462        signature_data,
463    })
464}
465
466fn push_ssh_string(out: &mut Vec<u8>, data: &[u8]) {
467    out.extend_from_slice(&(data.len() as u32).to_be_bytes());
468    out.extend_from_slice(data);
469}
470
471fn read_u32(data: &[u8], offset: &mut usize) -> Result<u32> {
472    if data.len().saturating_sub(*offset) < 4 {
473        return Err(Error::msg("truncated SSH signature"));
474    }
475    let value = u32::from_be_bytes(data[*offset..*offset + 4].try_into().unwrap());
476    *offset += 4;
477    Ok(value)
478}
479
480fn read_ssh_string(data: &[u8], offset: &mut usize) -> Result<Vec<u8>> {
481    let len = read_u32(data, offset)? as usize;
482    if data.len().saturating_sub(*offset) < len {
483        return Err(Error::msg("truncated SSH signature string"));
484    }
485    let value = data[*offset..*offset + len].to_vec();
486    *offset += len;
487    Ok(value)
488}
489
490fn armor_ssh_signature(blob: &[u8]) -> String {
491    let encoded = base64_encode(blob);
492    let mut out = String::from("-----BEGIN SSH SIGNATURE-----\n");
493    for chunk in encoded.as_bytes().chunks(70) {
494        out.push_str(std::str::from_utf8(chunk).unwrap());
495        out.push('\n');
496    }
497    out.push_str("-----END SSH SIGNATURE-----\n");
498    out
499}
500
501fn unarmor_ssh_signature(armored: &str) -> Result<Vec<u8>> {
502    let mut in_signature = false;
503    let mut encoded = String::new();
504    for line in armored.lines() {
505        if line.contains("BEGIN SSH SIGNATURE") {
506            in_signature = true;
507            continue;
508        }
509        if line.contains("END SSH SIGNATURE") {
510            break;
511        }
512        if in_signature {
513            encoded.push_str(line.trim());
514        }
515    }
516    if encoded.is_empty() {
517        return Err(Error::msg("no SSH signature data found"));
518    }
519    base64_decode(&encoded).ok_or_else(|| Error::msg("invalid base64 SSH signature"))
520}
521
522fn base64_encode(data: &[u8]) -> String {
523    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
524    let mut result = String::new();
525    let mut i = 0;
526    while i < data.len() {
527        let b0 = data[i] as u32;
528        let b1 = if i + 1 < data.len() {
529            data[i + 1] as u32
530        } else {
531            0
532        };
533        let b2 = if i + 2 < data.len() {
534            data[i + 2] as u32
535        } else {
536            0
537        };
538        let triple = (b0 << 16) | (b1 << 8) | b2;
539        result.push(CHARS[((triple >> 18) & 0x3f) as usize] as char);
540        result.push(CHARS[((triple >> 12) & 0x3f) as usize] as char);
541        result.push(if i + 1 < data.len() {
542            CHARS[((triple >> 6) & 0x3f) as usize] as char
543        } else {
544            '='
545        });
546        result.push(if i + 2 < data.len() {
547            CHARS[(triple & 0x3f) as usize] as char
548        } else {
549            '='
550        });
551        i += 3;
552    }
553    result
554}
555
556fn base64_decode(s: &str) -> Option<Vec<u8>> {
557    let mut chars: Vec<char> = s.trim().chars().filter(|c| !c.is_whitespace()).collect();
558    if chars.len() % 4 == 1 {
559        return None;
560    }
561    while chars.len() % 4 != 0 {
562        chars.push('=');
563    }
564    let mut out = Vec::with_capacity(chars.len() / 4 * 3);
565    for chunk in chars.chunks(4) {
566        let mut pad = 0usize;
567        let mut sextets = [0u8; 4];
568        for (i, c) in chunk.iter().copied().enumerate() {
569            if c == '=' {
570                pad += 1;
571                sextets[i] = 0;
572                continue;
573            }
574            if pad > 0 {
575                return None;
576            }
577            sextets[i] = match c {
578                'A'..='Z' => c as u8 - b'A',
579                'a'..='z' => c as u8 - b'a' + 26,
580                '0'..='9' => c as u8 - b'0' + 52,
581                '+' | '-' => 62,
582                '/' | '_' => 63,
583                _ => return None,
584            };
585        }
586        if pad > 2 {
587            return None;
588        }
589        let triple = ((sextets[0] as u32) << 18)
590            | ((sextets[1] as u32) << 12)
591            | ((sextets[2] as u32) << 6)
592            | (sextets[3] as u32);
593        out.push(((triple >> 16) & 0xff) as u8);
594        if pad < 2 {
595            out.push(((triple >> 8) & 0xff) as u8);
596        }
597        if pad < 1 {
598            out.push((triple & 0xff) as u8);
599        }
600    }
601    Some(out)
602}
603
604pub fn split_commit_signature(commit: &[u8]) -> (Option<String>, Vec<u8>) {
605    let Ok(text) = std::str::from_utf8(commit) else {
606        return (None, commit.to_vec());
607    };
608
609    let mut signature = Vec::new();
610    let mut signed = String::new();
611    let mut in_signature = false;
612    for line in text.split_inclusive('\n') {
613        let line_without_lf = line.strip_suffix('\n').unwrap_or(line);
614        if let Some(rest) = line_without_lf
615            .strip_prefix("gpgsig ")
616            .or_else(|| line_without_lf.strip_prefix("gpgsig-sha256 "))
617        {
618            in_signature = true;
619            signature.push(rest.to_string());
620            continue;
621        }
622        if in_signature {
623            if let Some(rest) = line_without_lf.strip_prefix(' ') {
624                signature.push(rest.to_string());
625                continue;
626            }
627            in_signature = false;
628        }
629        signed.push_str(line);
630    }
631
632    (
633        (!signature.is_empty()).then(|| signature.join("\n")),
634        signed.into_bytes(),
635    )
636}
637
638pub fn extract_signature_from_commit_object(commit: &[u8]) -> Option<String> {
639    let (signature, _) = split_commit_signature(commit);
640    signature
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use rns_crypto::OsRng;
647
648    fn commit_message(author: &str) -> Vec<u8> {
649        format!(
650            "tree 0123456789012345678901234567890123456789\nauthor Tester <{author}> 0 +0000\ncommitter Tester <{author}> 0 +0000\n\nmessage\n"
651        )
652        .into_bytes()
653    }
654
655    fn tag_message(tagger: &str) -> Vec<u8> {
656        format!(
657            "object 0123456789012345678901234567890123456789\ntype commit\ntag v1\ntagger Tester <{tagger}> 0 +0000\n\nmessage\n"
658        )
659        .into_bytes()
660    }
661
662    #[test]
663    fn sign_find_principal_and_verify_commit() {
664        let identity = Identity::new(&mut OsRng);
665        let author = crate::util::hex(identity.hash());
666        let message = commit_message(&author);
667        let armored = sign_message(&identity, &message).unwrap();
668
669        assert_eq!(find_principal(&armored).unwrap(), *identity.hash());
670        assert!(check_novalidate(&armored));
671
672        let status = verify_message_signature(&armored, &message, Some(&author));
673        assert!(status.valid);
674        assert!(status.author_match);
675        assert_eq!(status.signer_hash, Some(*identity.hash()));
676    }
677
678    #[test]
679    fn verify_tag_uses_tagger_identity() {
680        let identity = Identity::new(&mut OsRng);
681        let tagger = crate::util::hex(identity.hash());
682        let message = tag_message(&tagger);
683        let armored = sign_message(&identity, &message).unwrap();
684
685        let status = verify_message_signature(&armored, &message, Some(&tagger));
686        assert!(status.valid);
687        assert!(status.author_match);
688        assert_eq!(extract_tag_tagger(&message), (Some(tagger), true));
689    }
690
691    #[test]
692    fn verify_rejects_author_mismatch() {
693        let identity = Identity::new(&mut OsRng);
694        let message = commit_message("00112233445566778899aabbccddeeff");
695        let armored = sign_message(&identity, &message).unwrap();
696
697        let status = verify_message_signature(&armored, &message, None);
698        assert!(status.valid);
699        assert!(!status.author_match);
700    }
701
702    #[test]
703    fn verify_rejects_tampered_message() {
704        let identity = Identity::new(&mut OsRng);
705        let author = crate::util::hex(identity.hash());
706        let message = commit_message(&author);
707        let armored = sign_message(&identity, &message).unwrap();
708
709        let status = verify_message_signature(&armored, b"tampered", None);
710        assert!(!status.valid);
711    }
712
713    #[test]
714    fn extracts_multiline_gpgsig_from_commit_object() {
715        let signed = b"tree abc\nauthor A <a> 0 +0000\n\nmsg\n";
716        let commit = b"tree abc\ngpgsig -----BEGIN SSH SIGNATURE-----\n line1\n line2\n -----END SSH SIGNATURE-----\nauthor A <a> 0 +0000\n\nmsg\n";
717        let sig = extract_signature_from_commit_object(commit).unwrap();
718        assert!(sig.contains("BEGIN SSH SIGNATURE"));
719        assert!(sig.contains("line1"));
720        assert!(sig.contains("END SSH SIGNATURE"));
721        assert_eq!(split_commit_signature(commit).1, signed);
722    }
723}