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 =
380        Identity::from_public_key(&public_key).map_err(|_| Error::msg("invalid public key"))?;
381    let signer_hash = *identity.hash();
382    let meta_signer = meta
383        .map_get("signer")
384        .and_then(Value::as_bin)
385        .ok_or_else(|| Error::msg("RSG is missing signer"))?;
386    if meta_signer != signer_hash {
387        return Err(Error::msg("RSG signer does not match public key"));
388    }
389    if !identity.verify(&signature, envelope_bytes) {
390        return Err(Error::msg("invalid signature"));
391    }
392    Ok(signer_hash)
393}
394
395fn rsg_envelope(rsg: &[u8]) -> Result<Value> {
396    if rsg.len() <= SIG_LEN {
397        return Err(Error::msg("invalid RSG"));
398    }
399    msgpack::unpack_exact(&rsg[SIG_LEN..])
400        .map_err(|e| Error::msg(format!("invalid RSG envelope: {e}")))
401}
402
403fn git_ssh_public_key(identity: &Identity) -> Result<Vec<u8>> {
404    let public_key = identity
405        .get_public_key()
406        .ok_or_else(|| Error::msg("identity does not hold a public key"))?;
407    let mut out = Vec::new();
408    push_ssh_string(&mut out, b"ssh-ed25519");
409    push_ssh_string(&mut out, &public_key[32..]);
410    Ok(out)
411}
412
413pub struct ParsedSshSignature {
414    pub namespace: Vec<u8>,
415    pub signature_data: Vec<u8>,
416}
417
418pub fn parse_armored_ssh_signature(armored: &str) -> Result<ParsedSshSignature> {
419    let blob = unarmor_ssh_signature(armored)?;
420    parse_ssh_signature(&blob)
421}
422
423fn create_ssh_signature(
424    public_key_wire: &[u8],
425    namespace: &[u8],
426    reserved: &[u8],
427    hash_algorithm: &[u8],
428    signature_data: &[u8],
429) -> Vec<u8> {
430    let mut out = Vec::new();
431    out.extend_from_slice(SSHSIG_MAGIC);
432    out.extend_from_slice(&SSHSIG_VERSION.to_be_bytes());
433    push_ssh_string(&mut out, public_key_wire);
434    push_ssh_string(&mut out, namespace);
435    push_ssh_string(&mut out, reserved);
436    push_ssh_string(&mut out, hash_algorithm);
437    push_ssh_string(&mut out, signature_data);
438    out
439}
440
441fn parse_ssh_signature(blob: &[u8]) -> Result<ParsedSshSignature> {
442    let mut offset = 0usize;
443    if !blob.starts_with(SSHSIG_MAGIC) {
444        return Err(Error::msg("invalid SSH signature magic"));
445    }
446    offset += SSHSIG_MAGIC.len();
447    let version = read_u32(blob, &mut offset)?;
448    if version != SSHSIG_VERSION {
449        return Err(Error::msg(format!(
450            "unsupported SSH signature version {version}"
451        )));
452    }
453    let _public_key = read_ssh_string(blob, &mut offset)?;
454    let namespace = read_ssh_string(blob, &mut offset)?;
455    let _reserved = read_ssh_string(blob, &mut offset)?;
456    let hash_algorithm = read_ssh_string(blob, &mut offset)?;
457    if hash_algorithm != HASH_ALGORITHM {
458        return Err(Error::msg("unsupported SSH signature hash algorithm"));
459    }
460    let signature_data = read_ssh_string(blob, &mut offset)?;
461    Ok(ParsedSshSignature {
462        namespace,
463        signature_data,
464    })
465}
466
467fn push_ssh_string(out: &mut Vec<u8>, data: &[u8]) {
468    out.extend_from_slice(&(data.len() as u32).to_be_bytes());
469    out.extend_from_slice(data);
470}
471
472fn read_u32(data: &[u8], offset: &mut usize) -> Result<u32> {
473    if data.len().saturating_sub(*offset) < 4 {
474        return Err(Error::msg("truncated SSH signature"));
475    }
476    let value = u32::from_be_bytes(data[*offset..*offset + 4].try_into().unwrap());
477    *offset += 4;
478    Ok(value)
479}
480
481fn read_ssh_string(data: &[u8], offset: &mut usize) -> Result<Vec<u8>> {
482    let len = read_u32(data, offset)? as usize;
483    if data.len().saturating_sub(*offset) < len {
484        return Err(Error::msg("truncated SSH signature string"));
485    }
486    let value = data[*offset..*offset + len].to_vec();
487    *offset += len;
488    Ok(value)
489}
490
491fn armor_ssh_signature(blob: &[u8]) -> String {
492    let encoded = base64_encode(blob);
493    let mut out = String::from("-----BEGIN SSH SIGNATURE-----\n");
494    for chunk in encoded.as_bytes().chunks(70) {
495        out.push_str(std::str::from_utf8(chunk).unwrap());
496        out.push('\n');
497    }
498    out.push_str("-----END SSH SIGNATURE-----\n");
499    out
500}
501
502fn unarmor_ssh_signature(armored: &str) -> Result<Vec<u8>> {
503    let mut in_signature = false;
504    let mut encoded = String::new();
505    for line in armored.lines() {
506        if line.contains("BEGIN SSH SIGNATURE") {
507            in_signature = true;
508            continue;
509        }
510        if line.contains("END SSH SIGNATURE") {
511            break;
512        }
513        if in_signature {
514            encoded.push_str(line.trim());
515        }
516    }
517    if encoded.is_empty() {
518        return Err(Error::msg("no SSH signature data found"));
519    }
520    base64_decode(&encoded).ok_or_else(|| Error::msg("invalid base64 SSH signature"))
521}
522
523fn base64_encode(data: &[u8]) -> String {
524    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
525    let mut result = String::new();
526    let mut i = 0;
527    while i < data.len() {
528        let b0 = data[i] as u32;
529        let b1 = if i + 1 < data.len() {
530            data[i + 1] as u32
531        } else {
532            0
533        };
534        let b2 = if i + 2 < data.len() {
535            data[i + 2] as u32
536        } else {
537            0
538        };
539        let triple = (b0 << 16) | (b1 << 8) | b2;
540        result.push(CHARS[((triple >> 18) & 0x3f) as usize] as char);
541        result.push(CHARS[((triple >> 12) & 0x3f) as usize] as char);
542        result.push(if i + 1 < data.len() {
543            CHARS[((triple >> 6) & 0x3f) as usize] as char
544        } else {
545            '='
546        });
547        result.push(if i + 2 < data.len() {
548            CHARS[(triple & 0x3f) as usize] as char
549        } else {
550            '='
551        });
552        i += 3;
553    }
554    result
555}
556
557fn base64_decode(s: &str) -> Option<Vec<u8>> {
558    let mut chars: Vec<char> = s.trim().chars().filter(|c| !c.is_whitespace()).collect();
559    if chars.len() % 4 == 1 {
560        return None;
561    }
562    while !chars.len().is_multiple_of(4) {
563        chars.push('=');
564    }
565    let mut out = Vec::with_capacity(chars.len() / 4 * 3);
566    for chunk in chars.chunks(4) {
567        let mut pad = 0usize;
568        let mut sextets = [0u8; 4];
569        for (i, c) in chunk.iter().copied().enumerate() {
570            if c == '=' {
571                pad += 1;
572                sextets[i] = 0;
573                continue;
574            }
575            if pad > 0 {
576                return None;
577            }
578            sextets[i] = match c {
579                'A'..='Z' => c as u8 - b'A',
580                'a'..='z' => c as u8 - b'a' + 26,
581                '0'..='9' => c as u8 - b'0' + 52,
582                '+' | '-' => 62,
583                '/' | '_' => 63,
584                _ => return None,
585            };
586        }
587        if pad > 2 {
588            return None;
589        }
590        let triple = ((sextets[0] as u32) << 18)
591            | ((sextets[1] as u32) << 12)
592            | ((sextets[2] as u32) << 6)
593            | (sextets[3] as u32);
594        out.push(((triple >> 16) & 0xff) as u8);
595        if pad < 2 {
596            out.push(((triple >> 8) & 0xff) as u8);
597        }
598        if pad < 1 {
599            out.push((triple & 0xff) as u8);
600        }
601    }
602    Some(out)
603}
604
605pub fn split_commit_signature(commit: &[u8]) -> (Option<String>, Vec<u8>) {
606    let Ok(text) = std::str::from_utf8(commit) else {
607        return (None, commit.to_vec());
608    };
609
610    let mut signature = Vec::new();
611    let mut signed = String::new();
612    let mut in_signature = false;
613    for line in text.split_inclusive('\n') {
614        let line_without_lf = line.strip_suffix('\n').unwrap_or(line);
615        if let Some(rest) = line_without_lf
616            .strip_prefix("gpgsig ")
617            .or_else(|| line_without_lf.strip_prefix("gpgsig-sha256 "))
618        {
619            in_signature = true;
620            signature.push(rest.to_string());
621            continue;
622        }
623        if in_signature {
624            if let Some(rest) = line_without_lf.strip_prefix(' ') {
625                signature.push(rest.to_string());
626                continue;
627            }
628            in_signature = false;
629        }
630        signed.push_str(line);
631    }
632
633    (
634        (!signature.is_empty()).then(|| signature.join("\n")),
635        signed.into_bytes(),
636    )
637}
638
639pub fn extract_signature_from_commit_object(commit: &[u8]) -> Option<String> {
640    let (signature, _) = split_commit_signature(commit);
641    signature
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use rns_crypto::OsRng;
648
649    fn commit_message(author: &str) -> Vec<u8> {
650        format!(
651            "tree 0123456789012345678901234567890123456789\nauthor Tester <{author}> 0 +0000\ncommitter Tester <{author}> 0 +0000\n\nmessage\n"
652        )
653        .into_bytes()
654    }
655
656    fn tag_message(tagger: &str) -> Vec<u8> {
657        format!(
658            "object 0123456789012345678901234567890123456789\ntype commit\ntag v1\ntagger Tester <{tagger}> 0 +0000\n\nmessage\n"
659        )
660        .into_bytes()
661    }
662
663    #[test]
664    fn sign_find_principal_and_verify_commit() {
665        let identity = Identity::new(&mut OsRng);
666        let author = crate::util::hex(identity.hash());
667        let message = commit_message(&author);
668        let armored = sign_message(&identity, &message).unwrap();
669
670        assert_eq!(find_principal(&armored).unwrap(), *identity.hash());
671        assert!(check_novalidate(&armored));
672
673        let status = verify_message_signature(&armored, &message, Some(&author));
674        assert!(status.valid);
675        assert!(status.author_match);
676        assert_eq!(status.signer_hash, Some(*identity.hash()));
677    }
678
679    #[test]
680    fn verify_tag_uses_tagger_identity() {
681        let identity = Identity::new(&mut OsRng);
682        let tagger = crate::util::hex(identity.hash());
683        let message = tag_message(&tagger);
684        let armored = sign_message(&identity, &message).unwrap();
685
686        let status = verify_message_signature(&armored, &message, Some(&tagger));
687        assert!(status.valid);
688        assert!(status.author_match);
689        assert_eq!(extract_tag_tagger(&message), (Some(tagger), true));
690    }
691
692    #[test]
693    fn verify_rejects_author_mismatch() {
694        let identity = Identity::new(&mut OsRng);
695        let message = commit_message("00112233445566778899aabbccddeeff");
696        let armored = sign_message(&identity, &message).unwrap();
697
698        let status = verify_message_signature(&armored, &message, None);
699        assert!(status.valid);
700        assert!(!status.author_match);
701    }
702
703    #[test]
704    fn verify_rejects_tampered_message() {
705        let identity = Identity::new(&mut OsRng);
706        let author = crate::util::hex(identity.hash());
707        let message = commit_message(&author);
708        let armored = sign_message(&identity, &message).unwrap();
709
710        let status = verify_message_signature(&armored, b"tampered", None);
711        assert!(!status.valid);
712    }
713
714    #[test]
715    fn extracts_multiline_gpgsig_from_commit_object() {
716        let signed = b"tree abc\nauthor A <a> 0 +0000\n\nmsg\n";
717        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";
718        let sig = extract_signature_from_commit_object(commit).unwrap();
719        assert!(sig.contains("BEGIN SSH SIGNATURE"));
720        assert!(sig.contains("line1"));
721        assert!(sig.contains("END SSH SIGNATURE"));
722        assert_eq!(split_commit_signature(commit).1, signed);
723    }
724}