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().is_multiple_of(4) {
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}