use std::collections::HashSet;
use std::io::{BufRead, Read};
use buffer_redux::BufReader;
use chrono::SubsecRound;
use log::debug;
use nom::branch::alt;
use nom::bytes::streaming::take_until1;
use nom::character::streaming::line_ending;
use nom::combinator::{complete, map_res};
use nom::IResult;
use crate::armor::{self, header_parser, read_from_buf, BlockType, Headers};
use crate::crypto::hash::HashAlgorithm;
use crate::errors::Result;
use crate::line_writer::LineBreak;
use crate::normalize_lines::Normalized;
use crate::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
use crate::types::{KeyVersion, PublicKeyTrait, SecretKeyTrait};
use crate::{ArmorOptions, Deserializable, Signature, StandaloneSignature};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CleartextSignedMessage {
csf_encoded_text: String,
hashes: Vec<HashAlgorithm>,
signatures: Vec<StandaloneSignature>,
}
impl CleartextSignedMessage {
pub fn new<F>(
text: &str,
config: SignatureConfig,
key: &impl SecretKeyTrait,
key_pw: F,
) -> Result<Self>
where
F: FnOnce() -> String,
{
let signature_text: Vec<u8> = Normalized::new(text.bytes(), LineBreak::Crlf).collect();
let hash = config.hash_alg;
let signature = config.sign(key, key_pw, &signature_text[..])?;
let signature = StandaloneSignature::new(signature);
Ok(Self {
csf_encoded_text: dash_escape(text),
hashes: vec![hash],
signatures: vec![signature],
})
}
pub fn sign<R, F>(rng: R, text: &str, key: &impl SecretKeyTrait, key_pw: F) -> Result<Self>
where
R: rand::Rng + rand::CryptoRng,
F: FnOnce() -> String,
{
let key_id = key.key_id();
let algorithm = key.algorithm();
let hash_algorithm = key.hash_alg();
let hashed_subpackets = vec![
Subpacket::regular(SubpacketData::IssuerFingerprint(key.fingerprint())),
Subpacket::regular(SubpacketData::SignatureCreationTime(
chrono::Utc::now().trunc_subsecs(0),
)),
];
let unhashed_subpackets = vec![Subpacket::regular(SubpacketData::Issuer(key_id))];
let mut config = match key.version() {
KeyVersion::V4 => SignatureConfig::v4(SignatureType::Text, algorithm, hash_algorithm),
KeyVersion::V6 => {
SignatureConfig::v6(rng, SignatureType::Text, algorithm, hash_algorithm)?
}
v => bail!("unsupported key version {:?}", v),
};
config.hashed_subpackets = hashed_subpackets;
config.unhashed_subpackets = unhashed_subpackets;
Self::new(text, config, key, key_pw)
}
pub fn new_many<F>(text: &str, signer: F) -> Result<Self>
where
F: FnOnce(&[u8]) -> Result<Vec<Signature>>,
{
let signature_text: Vec<u8> = Normalized::new(text.bytes(), LineBreak::Crlf).collect();
let raw_signatures = signer(&signature_text[..])?;
let mut hashes = HashSet::new();
let mut signatures = Vec::new();
for signature in raw_signatures {
hashes.insert(signature.hash_alg());
let signature = StandaloneSignature::new(signature);
signatures.push(signature);
}
Ok(Self {
csf_encoded_text: dash_escape(text),
hashes: hashes.into_iter().collect(),
signatures,
})
}
pub fn signatures(&self) -> &[StandaloneSignature] {
&self.signatures
}
pub fn verify(&self, key: &impl PublicKeyTrait) -> Result<&StandaloneSignature> {
let nt = self.signed_text();
for signature in &self.signatures {
if signature.verify(key, nt.as_bytes()).is_ok() {
return Ok(signature);
}
}
bail!("No matching signature found")
}
pub fn verify_many<F>(&self, verifier: F) -> Result<()>
where
F: Fn(usize, &StandaloneSignature, &[u8]) -> Result<()>,
{
let nt = self.signed_text();
for (i, signature) in self.signatures.iter().enumerate() {
verifier(i, signature, nt.as_bytes())?;
}
Ok(())
}
pub fn signed_text(&self) -> String {
let unescaped = dash_unescape_and_trim(&self.csf_encoded_text);
let normalized: Vec<u8> = Normalized::new(unescaped.bytes(), LineBreak::Crlf).collect();
std::str::from_utf8(&normalized)
.map(str::to_owned)
.expect("csf_encoded_text is UTF8")
}
pub fn text(&self) -> &str {
&self.csf_encoded_text
}
pub fn from_armor<R: Read>(bytes: R) -> Result<(Self, Headers)> {
Self::from_armor_buf(BufReader::new(bytes))
}
pub fn from_string(input: &str) -> Result<(Self, Headers)> {
Self::from_armor_buf(input.as_bytes())
}
pub fn from_armor_buf<R: BufRead>(mut b: R) -> Result<(Self, Headers)> {
debug!("parsing cleartext message");
let (typ, headers, has_leading_data) =
read_from_buf(&mut b, "cleartext header", header_parser)?;
ensure_eq!(typ, BlockType::CleartextMessage, "unexpected block type");
ensure!(
!has_leading_data,
"must not have leading data for a cleartext message"
);
Self::from_armor_after_header(b, headers)
}
pub fn from_armor_after_header<R: BufRead>(
mut b: R,
headers: Headers,
) -> Result<(Self, Headers)> {
let hashes = validate_headers(headers)?;
debug!("Found Hash headers: {:?}", hashes);
let csf_encoded_text = read_from_buf(&mut b, "cleartext body", cleartext_body)?;
let mut dearmor = armor::Dearmor::new(b);
dearmor.read_header()?;
let typ = dearmor
.typ
.ok_or_else(|| format_err!("dearmor failed to retrieve armor type"))?;
ensure_eq!(typ, BlockType::Signature, "invalid block type");
let signatures = StandaloneSignature::from_bytes_many(&mut dearmor);
let signatures = signatures.collect::<Result<_>>()?;
let (_, headers, _, b) = dearmor.into_parts();
if has_rest(b)? {
bail!("unexpected trailing data");
}
Ok((
Self {
csf_encoded_text,
hashes,
signatures,
},
headers,
))
}
pub fn to_armored_writer(
&self,
writer: &mut impl std::io::Write,
opts: ArmorOptions<'_>,
) -> Result<()> {
writer.write_all(HEADER_LINE.as_bytes())?;
writer.write_all(&[b'\n'])?;
for hash in &self.hashes {
writer.write_all(b"Hash: ")?;
writer.write_all(hash.to_string().as_bytes())?;
writer.write_all(&[b'\n'])?;
}
writer.write_all(&[b'\n'])?;
writer.write_all(self.csf_encoded_text.as_bytes())?;
writer.write_all(&[b'\n'])?;
armor::write(
&self.signatures,
armor::BlockType::Signature,
writer,
opts.headers,
opts.include_checksum,
)?;
Ok(())
}
pub fn to_armored_bytes(&self, opts: ArmorOptions<'_>) -> Result<Vec<u8>> {
let mut buf = Vec::new();
self.to_armored_writer(&mut buf, opts)?;
Ok(buf)
}
pub fn to_armored_string(&self, opts: ArmorOptions<'_>) -> Result<String> {
let res = String::from_utf8(self.to_armored_bytes(opts)?).map_err(|e| e.utf8_error())?;
Ok(res)
}
}
fn validate_headers(headers: Headers) -> Result<Vec<HashAlgorithm>> {
let mut hashes = Vec::new();
for (name, values) in headers {
ensure_eq!(name, "Hash", "unexpected header");
for value in values {
let h: HashAlgorithm = value.parse()?;
hashes.push(h);
}
}
Ok(hashes)
}
fn dash_escape(text: &str) -> String {
let mut out = String::new();
for line in text.split_inclusive('\n') {
if line.starts_with('-') {
out += "- ";
}
out.push_str(line);
}
out
}
fn dash_unescape_and_trim(text: &str) -> String {
let mut out = String::new();
for line in text.split_inclusive('\n') {
let line_end_len = if line.ends_with("\r\n") {
2
} else if line.ends_with("\n") {
1
} else {
0
};
let (content, end) = line.split_at(line.len() - line_end_len);
let trimmed = content.trim_end_matches([' ', '\t']);
if let Some(stripped) = trimmed.strip_prefix("- ") {
out += stripped;
} else {
out += trimmed;
}
out += end;
}
out
}
fn has_rest<R: BufRead>(mut b: R) -> Result<bool> {
let mut buf = [0u8; 64];
while b.read(&mut buf)? > 0 {
if buf.iter().any(|&c| !char::from(c).is_ascii_whitespace()) {
return Ok(true);
}
}
Ok(false)
}
const HEADER_LINE: &str = "-----BEGIN PGP SIGNED MESSAGE-----";
fn to_string(b: &[u8]) -> std::result::Result<String, std::str::Utf8Error> {
std::str::from_utf8(b).map(|s| s.to_string())
}
fn cleartext_body(i: &[u8]) -> IResult<&[u8], String> {
let (i, lines) = map_res(
alt((
complete(take_until1("\r\n-----")),
complete(take_until1("\n-----")),
)),
to_string,
)(i)?;
let (i, _) = line_ending(i)?;
Ok((i, lines))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use super::*;
use crate::{Any, SignedPublicKey, SignedSecretKey};
#[test]
fn test_cleartext_openpgp_1() {
let _ = pretty_env_logger::try_init();
let data =
std::fs::read_to_string("./tests/openpgp/samplemsgs/clearsig-1-key-1.asc").unwrap();
let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
assert_eq!(normalize(msg.text()), normalize("You are scrupulously honest, frank, and straightforward. Therefore you\nhave few friends."));
assert_eq!(headers.len(), 1);
assert_eq!(
headers.get("Version").unwrap(),
&vec!["GnuPG v2".to_string()]
);
assert_eq!(msg.signatures().len(), 1);
roundtrip(&data, &msg, &headers);
}
#[test]
fn test_cleartext_openpgp_2() {
let _ = pretty_env_logger::try_init();
let data =
std::fs::read_to_string("./tests/openpgp/samplemsgs/clearsig-2-keys-1.asc").unwrap();
let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
assert_eq!(
normalize(msg.text()),
normalize("\"The geeks shall inherit the earth.\"\n -- Karl Lehenbauer")
);
assert_eq!(headers.len(), 1);
assert_eq!(
headers.get("Version").unwrap(),
&vec!["GnuPG v2".to_string()]
);
assert_eq!(msg.signatures().len(), 2);
roundtrip(&data, &msg, &headers);
}
#[test]
fn test_cleartext_openpgp_3() {
let _ = pretty_env_logger::try_init();
let data =
std::fs::read_to_string("./tests/openpgp/samplemsgs/clearsig-2-keys-2.asc").unwrap();
let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
assert_eq!(
normalize(msg.text()),
normalize("The very remembrance of my former misfortune proves a new one to me.\n -- Miguel de Cervantes")
);
assert_eq!(headers.len(), 1);
assert_eq!(
headers.get("Version").unwrap(),
&vec!["GnuPG v2".to_string()]
);
roundtrip(&data, &msg, &headers);
}
#[test]
fn test_cleartext_interop_testsuite_1_good() {
let _ = pretty_env_logger::try_init();
let data = std::fs::read_to_string("./tests/unit-tests/cleartext-msg-01.asc").unwrap();
let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
assert_eq!(
normalize(msg.text()),
normalize(
"- From the grocery store we need:\n\n- - tofu\n- - vegetables\n- - noodles\n\n"
)
);
assert!(headers.is_empty());
assert_eq!(
msg.signed_text(),
"From the grocery store we need:\r\n\r\n- tofu\r\n- vegetables\r\n- noodles\r\n\r\n"
);
let key_data = std::fs::read_to_string("./tests/unit-tests/cleartext-key-01.asc").unwrap();
let (key, _) = SignedSecretKey::from_string(&key_data).unwrap();
msg.verify(&key.public_key()).unwrap();
assert_eq!(msg.signatures().len(), 1);
roundtrip(&data, &msg, &headers);
}
#[test]
fn test_cleartext_interop_testsuite_1_any() {
let _ = pretty_env_logger::try_init();
let data = std::fs::read_to_string("./tests/unit-tests/cleartext-msg-01.asc").unwrap();
let (msg, headers) = CleartextSignedMessage::from_string(&data).unwrap();
let (any, headers2) = Any::from_string(&data).unwrap();
assert_eq!(headers, headers2);
if let Any::Cleartext(msg2) = any {
assert_eq!(msg, msg2);
} else {
panic!("got unexpected type of any: {:?}", any);
}
}
#[test]
fn test_cleartext_interop_testsuite_1_fail() {
let _ = pretty_env_logger::try_init();
let data = std::fs::read_to_string("./tests/unit-tests/cleartext-msg-01-fail.asc").unwrap();
let err = CleartextSignedMessage::from_string(&data).unwrap_err();
dbg!(err);
let err = Any::from_string(&data).unwrap_err();
dbg!(err);
}
#[test]
fn test_cleartext_interop_testsuite_2_fail() {
let _ = pretty_env_logger::try_init();
let data = std::fs::read_to_string("./tests/unit-tests/cleartext-msg-02-fail.asc").unwrap();
let err = CleartextSignedMessage::from_string(&data).unwrap_err();
dbg!(err);
let err = Any::from_string(&data).unwrap_err();
dbg!(err);
}
fn roundtrip(expected: &str, msg: &CleartextSignedMessage, headers: &Headers) {
let expected = normalize(expected);
let out = msg.to_armored_string(Some(headers).into()).unwrap();
let out = normalize(out);
assert_eq!(expected, out);
}
fn normalize(a: impl AsRef<str>) -> String {
a.as_ref().replace("\r\n", "\n").replace('\r', "\n")
}
#[test]
fn test_cleartext_body() {
assert_eq!(
cleartext_body(b"-- hello\n--world\n-----bla").unwrap(),
(&b"-----bla"[..], "-- hello\n--world".to_string())
);
assert_eq!(
cleartext_body(b"-- hello\r\n--world\r\n-----bla").unwrap(),
(&b"-----bla"[..], "-- hello\r\n--world".to_string())
);
}
#[test]
fn test_dash_escape() {
let input = "From the grocery store we need:
- tofu
- vegetables
- noodles
";
let expected = "From the grocery store we need:
- - tofu
- - vegetables
- - noodles
";
assert_eq!(dash_escape(input), expected);
}
#[test]
fn test_dash_unescape_and_trim() {
let input = "From the grocery store we need:
- - tofu\u{20}\u{20}
- - vegetables\t
- - noodles
";
let expected = "From the grocery store we need:
- tofu
- vegetables
- noodles
";
assert_eq!(dash_unescape_and_trim(input), expected);
}
#[test]
fn test_sign() {
let mut rng = ChaCha8Rng::seed_from_u64(0);
let key_data = std::fs::read_to_string("./tests/unit-tests/cleartext-key-01.asc").unwrap();
let (key, _) = SignedSecretKey::from_string(&key_data).unwrap();
let msg = CleartextSignedMessage::sign(
&mut rng,
"hello\n-world-what-\nis up\n",
&key,
String::new,
)
.unwrap();
msg.verify(&key.public_key()).unwrap();
}
#[test]
fn test_sign_no_newline() {
const MSG: &str = "message without newline at the end";
let mut rng = ChaCha8Rng::seed_from_u64(0);
let key_data = std::fs::read_to_string("./tests/unit-tests/cleartext-key-01.asc").unwrap();
let (key, _) = SignedSecretKey::from_string(&key_data).unwrap();
let msg = CleartextSignedMessage::sign(&mut rng, MSG, &key, String::new).unwrap();
assert_eq!(msg.signed_text(), MSG);
msg.verify(&key.public_key()).unwrap();
}
#[test]
fn test_verify_csf_puppet() {
let msg_data = std::fs::read_to_string("./tests/unit-tests/csf-puppet/InRelease").unwrap();
let (Any::Cleartext(msg), headers) = Any::from_string(&msg_data).unwrap() else {
panic!("couldn't read msg")
};
assert_eq!(headers.len(), 0);
assert_eq!(msg.signatures().len(), 1);
roundtrip(&msg_data, &msg, &headers);
let cert_data =
std::fs::read_to_string("./tests/unit-tests/csf-puppet/DEB-GPG-KEY-puppet-20250406")
.unwrap();
let (cert, _) = SignedPublicKey::from_string(&cert_data).unwrap();
msg.verify(&cert).expect("verify");
}
}