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}