cmail_rpgp/composed/
cleartext.rs

1//! Implements Cleartext Signature Framework
2
3use std::collections::HashSet;
4use std::io::{BufRead, Read};
5
6use buffer_redux::BufReader;
7use chrono::SubsecRound;
8use log::debug;
9use nom::branch::alt;
10use nom::bytes::streaming::take_until1;
11use nom::character::streaming::line_ending;
12use nom::combinator::{complete, map_res};
13use nom::IResult;
14
15use crate::armor::{self, header_parser, read_from_buf, BlockType, Headers};
16use crate::crypto::hash::HashAlgorithm;
17use crate::errors::Result;
18use crate::line_writer::LineBreak;
19use crate::normalize_lines::Normalized;
20use crate::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
21use crate::types::{KeyVersion, PublicKeyTrait, SecretKeyTrait};
22use crate::{ArmorOptions, Deserializable, Signature, StandaloneSignature};
23
24/// Implementation of a Cleartext Signed Message.
25///
26/// Ref <https://www.rfc-editor.org/rfc/rfc9580.html#name-cleartext-signature-framewo>
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CleartextSignedMessage {
29    /// Normalized and dash-escaped representation of the signed text.
30    /// This is exactly the format that gets serialized in cleartext format.
31    ///
32    /// This representation retains the line-ending encoding of the input material.
33    csf_encoded_text: String,
34
35    /// Hash algorithms that are used in the signature(s) in this message
36    hashes: Vec<HashAlgorithm>,
37
38    /// The actual signature(s).
39    signatures: Vec<StandaloneSignature>,
40}
41
42impl CleartextSignedMessage {
43    /// Construct a new cleartext message and sign it using the given key.
44    pub fn new<F>(
45        text: &str,
46        config: SignatureConfig,
47        key: &impl SecretKeyTrait,
48        key_pw: F,
49    ) -> Result<Self>
50    where
51        F: FnOnce() -> String,
52    {
53        let signature_text: Vec<u8> = Normalized::new(text.bytes(), LineBreak::Crlf).collect();
54        let hash = config.hash_alg;
55        let signature = config.sign(key, key_pw, &signature_text[..])?;
56        let signature = StandaloneSignature::new(signature);
57
58        Ok(Self {
59            csf_encoded_text: dash_escape(text),
60            hashes: vec![hash],
61            signatures: vec![signature],
62        })
63    }
64
65    /// Sign the given text.
66    pub fn sign<R, F>(rng: R, text: &str, key: &impl SecretKeyTrait, key_pw: F) -> Result<Self>
67    where
68        R: rand::Rng + rand::CryptoRng,
69        F: FnOnce() -> String,
70    {
71        let key_id = key.key_id();
72        let algorithm = key.algorithm();
73        let hash_algorithm = key.hash_alg();
74        let hashed_subpackets = vec![
75            Subpacket::regular(SubpacketData::IssuerFingerprint(key.fingerprint())),
76            Subpacket::regular(SubpacketData::SignatureCreationTime(
77                chrono::Utc::now().trunc_subsecs(0),
78            )),
79        ];
80        let unhashed_subpackets = vec![Subpacket::regular(SubpacketData::Issuer(key_id))];
81
82        let mut config = match key.version() {
83            KeyVersion::V4 => SignatureConfig::v4(SignatureType::Text, algorithm, hash_algorithm),
84            KeyVersion::V6 => {
85                SignatureConfig::v6(rng, SignatureType::Text, algorithm, hash_algorithm)?
86            }
87            v => bail!("unsupported key version {:?}", v),
88        };
89        config.hashed_subpackets = hashed_subpackets;
90        config.unhashed_subpackets = unhashed_subpackets;
91
92        Self::new(text, config, key, key_pw)
93    }
94
95    /// Sign the same message with multiple keys.
96    ///
97    /// The signer function gets invoked with the normalized original text to be signed,
98    /// and needs to produce the individual signatures.
99    pub fn new_many<F>(text: &str, signer: F) -> Result<Self>
100    where
101        F: FnOnce(&[u8]) -> Result<Vec<Signature>>,
102    {
103        let signature_text: Vec<u8> = Normalized::new(text.bytes(), LineBreak::Crlf).collect();
104
105        let raw_signatures = signer(&signature_text[..])?;
106        let mut hashes = HashSet::new();
107        let mut signatures = Vec::new();
108
109        for signature in raw_signatures {
110            hashes.insert(signature.hash_alg());
111            let signature = StandaloneSignature::new(signature);
112            signatures.push(signature);
113        }
114
115        Ok(Self {
116            csf_encoded_text: dash_escape(text),
117            hashes: hashes.into_iter().collect(),
118            signatures,
119        })
120    }
121
122    /// The signature on the message.
123    pub fn signatures(&self) -> &[StandaloneSignature] {
124        &self.signatures
125    }
126
127    /// Verify the signature against the normalized cleartext.
128    ///
129    /// On success returns the first signature that verified against this key.
130    pub fn verify(&self, key: &impl PublicKeyTrait) -> Result<&StandaloneSignature> {
131        let nt = self.signed_text();
132        for signature in &self.signatures {
133            if signature.verify(key, nt.as_bytes()).is_ok() {
134                return Ok(signature);
135            }
136        }
137
138        bail!("No matching signature found")
139    }
140
141    /// Verify each signature, potentially against a different key.
142    pub fn verify_many<F>(&self, verifier: F) -> Result<()>
143    where
144        F: Fn(usize, &StandaloneSignature, &[u8]) -> Result<()>,
145    {
146        let nt = self.signed_text();
147        for (i, signature) in self.signatures.iter().enumerate() {
148            verifier(i, signature, nt.as_bytes())?;
149        }
150        Ok(())
151    }
152
153    /// Normalizes the text to the format that was hashed for the signature.
154    /// The output is normalized to "\r\n" line endings.
155    pub fn signed_text(&self) -> String {
156        let unescaped = dash_unescape_and_trim(&self.csf_encoded_text);
157
158        let normalized: Vec<u8> = Normalized::new(unescaped.bytes(), LineBreak::Crlf).collect();
159
160        std::str::from_utf8(&normalized)
161            .map(str::to_owned)
162            .expect("csf_encoded_text is UTF8")
163    }
164
165    /// The "cleartext framework"-encoded (i.e. dash-escaped) form of the message.
166    pub fn text(&self) -> &str {
167        &self.csf_encoded_text
168    }
169
170    /// Parse from an arbitrary reader, containing the text of the message.
171    pub fn from_armor<R: Read>(bytes: R) -> Result<(Self, Headers)> {
172        Self::from_armor_buf(BufReader::new(bytes))
173    }
174
175    /// Parse from string, containing the text of the message.
176    pub fn from_string(input: &str) -> Result<(Self, Headers)> {
177        Self::from_armor_buf(input.as_bytes())
178    }
179
180    /// Parse from a buffered reader, containing the text of the message.
181    pub fn from_armor_buf<R: BufRead>(mut b: R) -> Result<(Self, Headers)> {
182        debug!("parsing cleartext message");
183        // Headers
184        let (typ, headers, has_leading_data) =
185            read_from_buf(&mut b, "cleartext header", header_parser)?;
186        ensure_eq!(typ, BlockType::CleartextMessage, "unexpected block type");
187        ensure!(
188            !has_leading_data,
189            "must not have leading data for a cleartext message"
190        );
191
192        Self::from_armor_after_header(b, headers)
193    }
194
195    pub fn from_armor_after_header<R: BufRead>(
196        mut b: R,
197        headers: Headers,
198    ) -> Result<(Self, Headers)> {
199        let hashes = validate_headers(headers)?;
200
201        debug!("Found Hash headers: {:?}", hashes);
202
203        // Cleartext Body
204        let csf_encoded_text = read_from_buf(&mut b, "cleartext body", cleartext_body)?;
205
206        // Signatures
207        let mut dearmor = armor::Dearmor::new(b);
208        dearmor.read_header()?;
209        // Safe to unwrap, as read_header succeeded.
210        let typ = dearmor
211            .typ
212            .ok_or_else(|| format_err!("dearmor failed to retrieve armor type"))?;
213
214        ensure_eq!(typ, BlockType::Signature, "invalid block type");
215
216        let signatures = StandaloneSignature::from_bytes_many(&mut dearmor);
217        let signatures = signatures.collect::<Result<_>>()?;
218
219        let (_, headers, _, b) = dearmor.into_parts();
220
221        if has_rest(b)? {
222            bail!("unexpected trailing data");
223        }
224
225        Ok((
226            Self {
227                csf_encoded_text,
228                hashes,
229                signatures,
230            },
231            headers,
232        ))
233    }
234
235    pub fn to_armored_writer(
236        &self,
237        writer: &mut impl std::io::Write,
238        opts: ArmorOptions<'_>,
239    ) -> Result<()> {
240        // Header
241        writer.write_all(HEADER_LINE.as_bytes())?;
242        writer.write_all(&[b'\n'])?;
243
244        // Hashes
245        for hash in &self.hashes {
246            writer.write_all(b"Hash: ")?;
247            writer.write_all(hash.to_string().as_bytes())?;
248            writer.write_all(&[b'\n'])?;
249        }
250        writer.write_all(&[b'\n'])?;
251
252        // Cleartext body
253        writer.write_all(self.csf_encoded_text.as_bytes())?;
254        writer.write_all(&[b'\n'])?;
255
256        armor::write(
257            &self.signatures,
258            armor::BlockType::Signature,
259            writer,
260            opts.headers,
261            opts.include_checksum,
262        )?;
263
264        Ok(())
265    }
266
267    pub fn to_armored_bytes(&self, opts: ArmorOptions<'_>) -> Result<Vec<u8>> {
268        let mut buf = Vec::new();
269        self.to_armored_writer(&mut buf, opts)?;
270        Ok(buf)
271    }
272
273    pub fn to_armored_string(&self, opts: ArmorOptions<'_>) -> Result<String> {
274        let res = String::from_utf8(self.to_armored_bytes(opts)?).map_err(|e| e.utf8_error())?;
275        Ok(res)
276    }
277}
278
279fn validate_headers(headers: Headers) -> Result<Vec<HashAlgorithm>> {
280    let mut hashes = Vec::new();
281    for (name, values) in headers {
282        ensure_eq!(name, "Hash", "unexpected header");
283        for value in values {
284            let h: HashAlgorithm = value.parse()?;
285            hashes.push(h);
286        }
287    }
288    Ok(hashes)
289}
290
291/// Dash escape the given text.
292///
293/// This implementation is implicitly agnostic between "\n" and "\r\n" line endings.
294///
295/// Ref <https://www.rfc-editor.org/rfc/rfc9580.html#name-dash-escaped-text>
296fn dash_escape(text: &str) -> String {
297    let mut out = String::new();
298    for line in text.split_inclusive('\n') {
299        if line.starts_with('-') {
300            out += "- ";
301        }
302        out.push_str(line);
303    }
304
305    out
306}
307
308/// Undo dash escaping of `text`, and trim space/tabs at the end of lines.
309///
310/// This implementation can handle both "\n" and "\r\n" line endings.
311fn dash_unescape_and_trim(text: &str) -> String {
312    let mut out = String::new();
313
314    for line in text.split_inclusive('\n') {
315        // break each line into "content" and "line ending"
316        let line_end_len = if line.ends_with("\r\n") {
317            2
318        } else if line.ends_with("\n") {
319            1
320        } else {
321            0
322        };
323        let (content, end) = line.split_at(line.len() - line_end_len);
324
325        // trim spaces/tabs from the end of line content
326        let trimmed = content.trim_end_matches([' ', '\t']);
327
328        // strip dash escapes if they exist
329        if let Some(stripped) = trimmed.strip_prefix("- ") {
330            out += stripped;
331        } else {
332            out += trimmed;
333        }
334
335        // output line ending
336        out += end;
337    }
338
339    out
340}
341
342/// Does the remaining buffer contain any non-whitespace characters?
343fn has_rest<R: BufRead>(mut b: R) -> Result<bool> {
344    let mut buf = [0u8; 64];
345    while b.read(&mut buf)? > 0 {
346        if buf.iter().any(|&c| !char::from(c).is_ascii_whitespace()) {
347            return Ok(true);
348        }
349    }
350
351    Ok(false)
352}
353
354const HEADER_LINE: &str = "-----BEGIN PGP SIGNED MESSAGE-----";
355
356fn to_string(b: &[u8]) -> std::result::Result<String, std::str::Utf8Error> {
357    std::str::from_utf8(b).map(|s| s.to_string())
358}
359
360fn cleartext_body(i: &[u8]) -> IResult<&[u8], String> {
361    let (i, lines) = map_res(
362        alt((
363            complete(take_until1("\r\n-----")),
364            complete(take_until1("\n-----")),
365        )),
366        to_string,
367    )(i)?;
368    let (i, _) = line_ending(i)?;
369
370    Ok((i, lines))
371}
372
373#[cfg(test)]
374mod tests {
375    #![allow(clippy::unwrap_used)]
376
377    use rand::SeedableRng;
378    use rand_chacha::ChaCha8Rng;
379
380    use super::*;
381    use crate::{Any, SignedPublicKey, SignedSecretKey};
382
383    #[test]
384    fn test_cleartext_openpgp_1() {
385        let _ = pretty_env_logger::try_init();
386
387        let data =
388            std::fs::read_to_string("./tests/openpgp/samplemsgs/clearsig-1-key-1.asc").unwrap();
389
390        let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
391
392        assert_eq!(normalize(msg.text()), normalize("You are scrupulously honest, frank, and straightforward.  Therefore you\nhave few friends."));
393        assert_eq!(headers.len(), 1);
394        assert_eq!(
395            headers.get("Version").unwrap(),
396            &vec!["GnuPG v2".to_string()]
397        );
398
399        assert_eq!(msg.signatures().len(), 1);
400
401        roundtrip(&data, &msg, &headers);
402    }
403
404    #[test]
405    fn test_cleartext_openpgp_2() {
406        let _ = pretty_env_logger::try_init();
407
408        let data =
409            std::fs::read_to_string("./tests/openpgp/samplemsgs/clearsig-2-keys-1.asc").unwrap();
410
411        let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
412
413        assert_eq!(
414            normalize(msg.text()),
415            normalize("\"The geeks shall inherit the earth.\"\n		-- Karl Lehenbauer")
416        );
417        assert_eq!(headers.len(), 1);
418        assert_eq!(
419            headers.get("Version").unwrap(),
420            &vec!["GnuPG v2".to_string()]
421        );
422
423        assert_eq!(msg.signatures().len(), 2);
424
425        roundtrip(&data, &msg, &headers);
426    }
427
428    #[test]
429    fn test_cleartext_openpgp_3() {
430        let _ = pretty_env_logger::try_init();
431
432        let data =
433            std::fs::read_to_string("./tests/openpgp/samplemsgs/clearsig-2-keys-2.asc").unwrap();
434
435        let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
436
437        assert_eq!(
438            normalize(msg.text()),
439            normalize("The very remembrance of my former misfortune proves a new one to me.\n		-- Miguel de Cervantes")
440        );
441        assert_eq!(headers.len(), 1);
442        assert_eq!(
443            headers.get("Version").unwrap(),
444            &vec!["GnuPG v2".to_string()]
445        );
446
447        roundtrip(&data, &msg, &headers);
448    }
449
450    #[test]
451    fn test_cleartext_interop_testsuite_1_good() {
452        let _ = pretty_env_logger::try_init();
453
454        let data = std::fs::read_to_string("./tests/unit-tests/cleartext-msg-01.asc").unwrap();
455
456        let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
457
458        assert_eq!(
459            normalize(msg.text()),
460            normalize(
461                "- From the grocery store we need:\n\n- - tofu\n- - vegetables\n- - noodles\n\n"
462            )
463        );
464        assert!(headers.is_empty());
465
466        assert_eq!(
467            msg.signed_text(),
468            "From the grocery store we need:\r\n\r\n- tofu\r\n- vegetables\r\n- noodles\r\n\r\n"
469        );
470
471        let key_data = std::fs::read_to_string("./tests/unit-tests/cleartext-key-01.asc").unwrap();
472        let (key, _) = SignedSecretKey::from_string(&key_data).unwrap();
473
474        msg.verify(&key.public_key()).unwrap();
475        assert_eq!(msg.signatures().len(), 1);
476
477        roundtrip(&data, &msg, &headers);
478    }
479
480    #[test]
481    fn test_cleartext_interop_testsuite_1_any() {
482        let _ = pretty_env_logger::try_init();
483
484        let data = std::fs::read_to_string("./tests/unit-tests/cleartext-msg-01.asc").unwrap();
485
486        let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
487
488        let (any, headers2) = Any::from_string(&data).unwrap();
489        assert_eq!(headers, headers2);
490
491        if let Any::Cleartext(msg2) = any {
492            assert_eq!(msg, msg2);
493        } else {
494            panic!("got unexpected type of any: {:?}", any);
495        }
496    }
497
498    #[test]
499    fn test_cleartext_interop_testsuite_1_fail() {
500        let _ = pretty_env_logger::try_init();
501
502        let data = std::fs::read_to_string("./tests/unit-tests/cleartext-msg-01-fail.asc").unwrap();
503
504        let err = CleartextSignedMessage::from_string(&data).unwrap_err();
505        dbg!(err);
506
507        let err = Any::from_string(&data).unwrap_err();
508        dbg!(err);
509    }
510
511    #[test]
512    fn test_cleartext_interop_testsuite_2_fail() {
513        let _ = pretty_env_logger::try_init();
514
515        let data = std::fs::read_to_string("./tests/unit-tests/cleartext-msg-02-fail.asc").unwrap();
516
517        let err = CleartextSignedMessage::from_string(&data).unwrap_err();
518        dbg!(err);
519
520        let err = Any::from_string(&data).unwrap_err();
521        dbg!(err);
522    }
523
524    fn roundtrip(expected: &str, msg: &CleartextSignedMessage, headers: &Headers) {
525        let expected = normalize(expected);
526        let out = msg.to_armored_string(Some(headers).into()).unwrap();
527        let out = normalize(out);
528
529        assert_eq!(expected, out);
530    }
531
532    fn normalize(a: impl AsRef<str>) -> String {
533        a.as_ref().replace("\r\n", "\n").replace('\r', "\n")
534    }
535
536    #[test]
537    fn test_cleartext_body() {
538        assert_eq!(
539            cleartext_body(b"-- hello\n--world\n-----bla").unwrap(),
540            (&b"-----bla"[..], "-- hello\n--world".to_string())
541        );
542
543        assert_eq!(
544            cleartext_body(b"-- hello\r\n--world\r\n-----bla").unwrap(),
545            (&b"-----bla"[..], "-- hello\r\n--world".to_string())
546        );
547    }
548
549    #[test]
550    fn test_dash_escape() {
551        let input = "From the grocery store we need:
552
553- tofu
554- vegetables
555- noodles
556
557";
558        let expected = "From the grocery store we need:
559
560- - tofu
561- - vegetables
562- - noodles
563
564";
565
566        assert_eq!(dash_escape(input), expected);
567    }
568
569    #[test]
570    fn test_dash_unescape_and_trim() {
571        let input = "From the grocery store we need:
572
573- - tofu\u{20}\u{20}
574- - vegetables\t
575- - noodles
576
577";
578        let expected = "From the grocery store we need:
579
580- tofu
581- vegetables
582- noodles
583
584";
585
586        assert_eq!(dash_unescape_and_trim(input), expected);
587    }
588
589    #[test]
590    fn test_sign() {
591        let mut rng = ChaCha8Rng::seed_from_u64(0);
592
593        let key_data = std::fs::read_to_string("./tests/unit-tests/cleartext-key-01.asc").unwrap();
594        let (key, _) = SignedSecretKey::from_string(&key_data).unwrap();
595        let msg = CleartextSignedMessage::sign(
596            &mut rng,
597            "hello\n-world-what-\nis up\n",
598            &key,
599            String::new,
600        )
601        .unwrap();
602        msg.verify(&key.public_key()).unwrap();
603    }
604
605    #[test]
606    fn test_sign_no_newline() {
607        const MSG: &str = "message without newline at the end";
608        let mut rng = ChaCha8Rng::seed_from_u64(0);
609
610        let key_data = std::fs::read_to_string("./tests/unit-tests/cleartext-key-01.asc").unwrap();
611        let (key, _) = SignedSecretKey::from_string(&key_data).unwrap();
612        let msg = CleartextSignedMessage::sign(&mut rng, MSG, &key, String::new).unwrap();
613
614        assert_eq!(msg.signed_text(), MSG);
615
616        msg.verify(&key.public_key()).unwrap();
617    }
618
619    #[test]
620    fn test_verify_csf_puppet() {
621        // test data via https://github.com/rpgp/rpgp/issues/424
622
623        let msg_data = std::fs::read_to_string("./tests/unit-tests/csf-puppet/InRelease").unwrap();
624        let (Any::Cleartext(msg), headers) = Any::from_string(&msg_data).unwrap() else {
625            panic!("couldn't read msg")
626        };
627
628        // superficially look at message
629        assert_eq!(headers.len(), 0);
630        assert_eq!(msg.signatures().len(), 1);
631        roundtrip(&msg_data, &msg, &headers);
632
633        // validate signature
634        let cert_data =
635            std::fs::read_to_string("./tests/unit-tests/csf-puppet/DEB-GPG-KEY-puppet-20250406")
636                .unwrap();
637        let (cert, _) = SignedPublicKey::from_string(&cert_data).unwrap();
638
639        msg.verify(&cert).expect("verify");
640    }
641}