mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Add craft-openpgp: OpenPGP signature checking and key reading from MailCraft
Moved from MailCraft's crates/openpgp at a44a45013d1c. Built on rPGP 0.21.0 as there. The one code change: the Unicode format-character test the From address check used from mailcraft-names is now a private function here, with a test.
This commit is contained in:
1 parent
6cb00467a5
commit
373c242ad7
23 files changed
+5234
-10
No files matched your search
Generated
+1061
-10
File diff suppressed because it is too large.
Load diff
@@ -88,6 +88,7 @@ craft-menu = { path = "crates/menu" }
|
||||
craft-metafile = { path = "crates/metafile" }
|
||||
craft-offcrypto = { path = "crates/offcrypto" }
|
||||
craft-opc = { path = "crates/opc" }
|
||||
craft-openpgp = { path = "crates/openpgp" }
|
||||
craft-ora = { path = "crates/ora" }
|
||||
craft-pptx = { path = "crates/pptx" }
|
||||
craft-penpot = { path = "crates/penpot" }
|
||||
|
||||
@@ -241,6 +241,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
header fields, MIME parts and multiparts, encoded-words, RFC 2231 parameters, base64 and
|
||||
quoted-printable, character sets, address lists, dates, mbox files and the `multipart/related`
|
||||
lookups of MHT web archives. What a message means (body, attachments, flags) stays in each app.
|
||||
- [`craft-openpgp`](crates/openpgp/README.md): OpenPGP as mail carries it: PGP/MIME and cleartext
|
||||
Digital signatures checked, attached Public keys and `Autocrypt:` headers read, a secret key
|
||||
file inspected without using it. Built on rPGP; no I/O, and every size and count is capped.
|
||||
- [`craft-odf`](crates/odf/README.md): OpenDocument packages (ODF 1.3 Part 2), read and written: the ZIP container with its `mimetype` and manifest within limits, password protection (AES-256-CBC with PBKDF2, as LibreOffice writes it) and flat single-file documents. With the `text` feature, OpenDocument Text documents read into and written from the craft-flow model.
|
||||
- [`craft-prc`](crates/prc/README.md): a reader for PRC (ISO 14739-1), the 3D model format of PDF 3D
|
||||
annotations: container, bit-level primitives, schema, tessellations and the product tree as
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "craft-openpgp"
|
||||
description = "OpenPGP for mail: PGP/MIME and cleartext Digital signatures checked, attached Public keys and Autocrypt headers read, a secret key file inspected; no I/O of its own."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# rPGP: the OpenPGP packets and the cryptography (MIT OR Apache-2.0). Pinned: every size cap in
|
||||
# this crate was written against this version's parser.
|
||||
pgp = { version = "=0.21.0", default-features = false }
|
||||
thiserror = { workspace = true }
|
||||
# rPGP's generation of RustCrypto digests (MIT OR Apache-2.0).
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
sha3 = { version = "0.10", default-features = false }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Random numbers in the browser, which rPGP's dependencies ask for even when it only checks.
|
||||
pgp = { version = "=0.21.0", default-features = false, features = ["wasm"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
# Keys and signatures made in the tests, from a seed: the random numbers rPGP 0.21 takes.
|
||||
rand = { version = "0.8", default-features = false, features = ["std", "std_rng"] }
|
||||
sha1 = { version = "0.10", default-features = false }
|
||||
ed25519-dalek = { version = "2", default-features = false }
|
||||
rsa = { version = "0.9", default-features = false }
|
||||
@@ -0,0 +1,31 @@
|
||||
# craft-openpgp
|
||||
|
||||
OpenPGP as mail carries it: Digital signatures checked and Public keys read. No I/O, no clock and
|
||||
no keys of its own. The caller hands in the message's bytes, the Public keys it keeps, the time,
|
||||
and whether the owner has verified a key.
|
||||
|
||||
- **`verify`** checks a PGP/MIME message (`multipart/signed;
|
||||
protocol="application/pgp-signature"`, RFC 3156 §5) over the signed part's canonical bytes, or a
|
||||
cleartext-signed text (RFC 9580 §7), and gives an `Outcome` (verified, valid but not verified,
|
||||
bad, or can't be checked) with what the signature covers (`Coverage`). MD5, SHA-1 and
|
||||
RIPEMD-160 signatures are refused as no longer safe.
|
||||
- **`keys_in_message`** reads the keys a message carries: attached `application/pgp-keys` parts
|
||||
and the `Autocrypt:` header (Autocrypt Level 1). Nothing is fetched or sent.
|
||||
- **`PublicKey`, `KeyRing`, `Fingerprint`, `KeyId`**: transferable public keys (armoured or
|
||||
binary) with their self-signatures, expiry and revocations checked.
|
||||
- **`parse_autocrypt`**: one `Autocrypt:` header value.
|
||||
- **`secret::read`, `secret::holds_secret`**: what a transferable secret key file is (its
|
||||
fingerprint, addresses, public half, whether every secret part has a passphrase, whether it can
|
||||
sign), without keeping or using the secret.
|
||||
|
||||
The packets and the cryptography are [rPGP](https://github.com/rpgp/rpgp)'s (`pgp`, MIT OR
|
||||
Apache-2.0). The armour, the Cleartext Signature Framework and the little MIME needed to find the
|
||||
signed bytes exactly as they came are read here, from RFC 2045/2046, RFC 3156 and RFC 9580, so
|
||||
that every size is capped by this crate.
|
||||
|
||||
Everything from outside is hostile: messages past `MAX_MESSAGE` are not read, signatures, keys,
|
||||
packets, user IDs, subkeys, MIME parts, nesting and the number of signature checks per call are
|
||||
capped, work is linear in the input, and a panic inside rPGP is caught and reported as an
|
||||
unreadable signature.
|
||||
|
||||
Moved from MailCraft's `mailcraft-openpgp`.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 9769ed1f0f9ff8e8afbdb639c2d3a0bad49d0a89c70a977a791d7b53ca178cea # shrinks to bytes = [45, 45, 45, 45, 45, 66, 69, 71, 73, 78, 32, 80, 71, 80, 32, 83, 73, 71, 78, 65, 84, 85, 82, 69, 45, 45, 45, 45, 45, 13, 10, 45, 45, 45, 45, 45, 66, 69, 71, 73, 78, 32, 80, 71, 80, 32, 80, 85, 66, 76, 73, 67, 32, 75, 69, 89, 32, 66, 76, 79, 67, 75, 45, 45, 45, 45, 45, 13, 10, 45, 45, 45, 45, 45, 69, 78, 68, 32, 80, 71, 80, 32, 83, 73, 71, 78, 65, 84, 85, 82, 69, 45, 45, 45, 45, 45, 13, 10], limit = 27, max = 0
|
||||
@@ -0,0 +1,236 @@
|
||||
//! OpenPGP armour (RFC 9580 §6.2) and the Cleartext Signature Framework (RFC 9580 §7), read
|
||||
//! here rather than in rPGP so that every size is ours to cap: the lines of a block are found,
|
||||
//! its base64 is decoded up to a limit, and a cleartext-signed text is given back as the bytes
|
||||
//! that were signed. Linear in the input; nothing here fails on any bytes.
|
||||
|
||||
use crate::mime;
|
||||
|
||||
/// The kinds of armour block this crate reads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Kind {
|
||||
Signature,
|
||||
PublicKey,
|
||||
PrivateKey,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn label(self) -> &'static [u8] {
|
||||
match self {
|
||||
Kind::Signature => b"SIGNATURE",
|
||||
Kind::PublicKey => b"PUBLIC KEY BLOCK",
|
||||
Kind::PrivateKey => b"PRIVATE KEY BLOCK",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of `text`: its bytes without the line break, and without the white space it ends
|
||||
/// in as well.
|
||||
struct Line<'a> {
|
||||
raw: &'a [u8],
|
||||
text: &'a [u8],
|
||||
/// Where it starts in the text, and where the next line starts.
|
||||
start: usize,
|
||||
next: usize,
|
||||
}
|
||||
|
||||
fn lines(text: &[u8]) -> impl Iterator<Item = Line<'_>> {
|
||||
let mut at = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
if at >= text.len() {
|
||||
return None;
|
||||
}
|
||||
let rest = text.get(at..).unwrap_or_default();
|
||||
let len = rest.iter().position(|b| *b == b'\n');
|
||||
let raw = rest.get(..len.unwrap_or(rest.len())).unwrap_or_default();
|
||||
let start = at;
|
||||
at = len.map_or(text.len(), |n| at.saturating_add(n).saturating_add(1));
|
||||
let raw = raw.strip_suffix(b"\r").unwrap_or(raw);
|
||||
Some(Line { raw, text: trim_end(raw), start, next: at })
|
||||
})
|
||||
}
|
||||
|
||||
/// `line` without the spaces, tabs and CR it ends in.
|
||||
pub(crate) fn trim_end(line: &[u8]) -> &[u8] {
|
||||
let end = line.iter().rposition(|b| !matches!(b, b' ' | b'\t' | b'\r')).map_or(0, |i| i.saturating_add(1));
|
||||
line.get(..end).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn marker(edge: &[u8], label: &[u8]) -> Vec<u8> {
|
||||
let mut m = Vec::with_capacity(label.len().saturating_add(20));
|
||||
m.extend_from_slice(b"-----");
|
||||
m.extend_from_slice(edge);
|
||||
m.extend_from_slice(b" PGP ");
|
||||
m.extend_from_slice(label);
|
||||
m.extend_from_slice(b"-----");
|
||||
m
|
||||
}
|
||||
|
||||
/// The decoded contents of every armour block of `kind` in `text`, at most `max_blocks`, each
|
||||
/// at most `limit` bytes (a larger one is skipped). A byte-order mark before a line is allowed.
|
||||
pub(crate) fn blocks(text: &[u8], kind: Kind, max_blocks: usize, limit: usize) -> Vec<Vec<u8>> {
|
||||
let begin = marker(b"BEGIN", kind.label());
|
||||
let end = marker(b"END", kind.label());
|
||||
let mut out = Vec::new();
|
||||
if max_blocks == 0 {
|
||||
return out;
|
||||
}
|
||||
let mut inside: Option<Body> = None;
|
||||
for line in lines(text) {
|
||||
let line_text = line.text.strip_prefix(b"\xef\xbb\xbf").unwrap_or(line.text);
|
||||
match inside.as_mut() {
|
||||
None if line_text == begin.as_slice() => inside = Some(Body::new(limit)),
|
||||
None => {}
|
||||
Some(body) if line_text == end.as_slice() => {
|
||||
if let Some(bytes) = body.finish() {
|
||||
out.push(bytes);
|
||||
if out.len() >= max_blocks {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
inside = None;
|
||||
}
|
||||
Some(body) if line_text == begin.as_slice() => *body = Body::new(limit),
|
||||
Some(body) => body.line(line_text),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The base64 of one block as its lines come: armour headers until the first empty line, then
|
||||
/// data until the checksum line (`=` and four characters), which RFC 9580 lets a reader ignore.
|
||||
struct Body {
|
||||
in_headers: bool,
|
||||
data: Vec<u8>,
|
||||
limit: usize,
|
||||
too_large: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl Body {
|
||||
fn new(limit: usize) -> Body {
|
||||
Body { in_headers: true, data: Vec::new(), limit, too_large: false, done: false }
|
||||
}
|
||||
|
||||
fn line(&mut self, line: &[u8]) {
|
||||
if self.done || self.too_large {
|
||||
return;
|
||||
}
|
||||
if self.in_headers {
|
||||
if line.is_empty() {
|
||||
self.in_headers = false;
|
||||
return;
|
||||
}
|
||||
// Armour without headers or the empty line after them: data from the first line.
|
||||
if line.contains(&b':') {
|
||||
return;
|
||||
}
|
||||
self.in_headers = false;
|
||||
}
|
||||
if line.first() == Some(&b'=') {
|
||||
self.done = true;
|
||||
return;
|
||||
}
|
||||
// Four characters of base64 are three bytes: stop collecting well before the limit.
|
||||
if self.data.len().saturating_add(line.len()) / 4 * 3 > self.limit {
|
||||
self.too_large = true;
|
||||
return;
|
||||
}
|
||||
self.data.extend_from_slice(line);
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Option<Vec<u8>> {
|
||||
if self.too_large {
|
||||
return None;
|
||||
}
|
||||
let bytes = mime::base64(&self.data);
|
||||
(!bytes.is_empty() && bytes.len() <= self.limit).then_some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The hash names a `Hash:` armour header may give (RFC 9580 §7.1, the names of §9.5).
|
||||
const HASH_NAMES: [&[u8]; 9] = [b"MD5", b"SHA1", b"RIPEMD160", b"SHA224", b"SHA256", b"SHA384", b"SHA512", b"SHA3-256", b"SHA3-512"];
|
||||
|
||||
/// Whether `line` is a `Hash:` header whose value is nothing but hash names, separated by
|
||||
/// commas: any other words there would be shown and aren't signed.
|
||||
fn hash_header(line: &[u8]) -> bool {
|
||||
let Some(value) = line.strip_prefix(b"Hash:") else { return false };
|
||||
value.split(|b| *b == b',').all(|name| {
|
||||
let name = name.trim_ascii();
|
||||
HASH_NAMES.iter().any(|known| name.eq_ignore_ascii_case(known))
|
||||
})
|
||||
}
|
||||
|
||||
/// A cleartext-signed text (RFC 9580 §7): the bytes that were signed and the decoded
|
||||
/// signature block after them.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Cleartext {
|
||||
/// The text as it was signed: dash-escaping undone, white space at each line's end removed,
|
||||
/// lines joined by CR LF and no line break after the last one.
|
||||
pub(crate) signed: Vec<u8>,
|
||||
/// The signature packets, decoded from their armour.
|
||||
pub(crate) signature: Vec<u8>,
|
||||
/// Where the block is in the text: from its first line to the end of its last.
|
||||
pub(crate) span: (usize, usize),
|
||||
/// Nothing but white space around the block, and so no other block: the block is the text.
|
||||
pub(crate) alone: bool,
|
||||
/// Its armour headers are `Hash:` lines only, and no line of the signed text is unescaped
|
||||
/// armour. One that isn't would show words that aren't signed, so it can't be checked.
|
||||
pub(crate) well_formed: bool,
|
||||
}
|
||||
|
||||
/// The first cleartext-signed text in `text`, if there is a whole one: `-----BEGIN PGP SIGNED
|
||||
/// MESSAGE-----`, its `Hash:` headers and an empty line, the text, and a signature block, which
|
||||
/// may be at most `limit` bytes.
|
||||
pub(crate) fn cleartext(text: &[u8], limit: usize) -> Option<Cleartext> {
|
||||
let begin = marker(b"BEGIN", b"SIGNED MESSAGE");
|
||||
let sig_begin = marker(b"BEGIN", Kind::Signature.label());
|
||||
let sig_end = marker(b"END", Kind::Signature.label());
|
||||
#[derive(PartialEq)]
|
||||
enum At {
|
||||
Before,
|
||||
Headers,
|
||||
Text,
|
||||
Signature,
|
||||
}
|
||||
let mut at = At::Before;
|
||||
let mut signed: Vec<u8> = Vec::new();
|
||||
let mut first_line = true;
|
||||
let mut body = Body::new(limit);
|
||||
let (mut start, mut alone, mut well_formed) = (0usize, true, true);
|
||||
for line in lines(text) {
|
||||
let line_text = line.text.strip_prefix(b"\xef\xbb\xbf").unwrap_or(line.text);
|
||||
match at {
|
||||
At::Before if line_text == begin.as_slice() => {
|
||||
at = At::Headers;
|
||||
start = line.start;
|
||||
}
|
||||
At::Before => alone &= line_text.iter().all(u8::is_ascii_whitespace),
|
||||
// RFC 9580 §7.1: `Hash:` lines (or none, in newer writers), then an empty line.
|
||||
// Anything else there would be shown and isn't signed.
|
||||
At::Headers if line_text.is_empty() => at = At::Text,
|
||||
At::Headers => well_formed &= hash_header(line_text),
|
||||
At::Text if line_text == sig_begin.as_slice() => at = At::Signature,
|
||||
At::Text => {
|
||||
if !first_line {
|
||||
signed.extend_from_slice(b"\r\n");
|
||||
}
|
||||
first_line = false;
|
||||
// Dash-escaping undone (§7.1), then the white space the line ends in removed
|
||||
// (§7.2). Every line that starts with a dash is escaped: one that isn't is
|
||||
// armour where text belongs.
|
||||
let raw = line.raw.strip_prefix(b"\xef\xbb\xbf").unwrap_or(line.raw);
|
||||
if raw.starts_with(b"-") && !raw.starts_with(b"- ") {
|
||||
well_formed = false;
|
||||
}
|
||||
signed.extend_from_slice(trim_end(raw.strip_prefix(b"- ").unwrap_or(raw)));
|
||||
}
|
||||
At::Signature if line_text == sig_end.as_slice() => {
|
||||
let signature = body.finish()?;
|
||||
alone &= text.get(line.next..).unwrap_or_default().iter().all(u8::is_ascii_whitespace);
|
||||
return Some(Cleartext { signed, signature, span: (start, line.next), alone, well_formed });
|
||||
}
|
||||
At::Signature => body.line(line_text),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::armour::{Kind, blocks, cleartext};
|
||||
|
||||
#[test]
|
||||
fn a_block_is_decoded_without_its_headers_or_checksum() {
|
||||
let text =
|
||||
b"before\r\n-----BEGIN PGP SIGNATURE-----\r\nComment: made by hand\r\n\r\nSGVs\r\nbG8=\r\n=AAAA\r\n-----END PGP SIGNATURE-----\r\nafter";
|
||||
assert_eq!(blocks(text, Kind::Signature, 4, 100), vec![b"Hello".to_vec()]);
|
||||
// Armour with no headers and no empty line after the first line.
|
||||
let text = b"-----BEGIN PGP SIGNATURE-----\nSGVsbG8=\n-----END PGP SIGNATURE-----\n";
|
||||
assert_eq!(blocks(text, Kind::Signature, 4, 100), vec![b"Hello".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_are_capped_in_size_and_number() {
|
||||
let block = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nSGVsbG8=\n-----END PGP PUBLIC KEY BLOCK-----\n";
|
||||
let many = block.repeat(10);
|
||||
assert_eq!(blocks(many.as_bytes(), Kind::PublicKey, 3, 100).len(), 3);
|
||||
assert!(blocks(block.as_bytes(), Kind::PublicKey, 3, 4).is_empty());
|
||||
// Another kind of block, an unclosed one, and an empty one are not blocks.
|
||||
assert!(blocks(block.as_bytes(), Kind::Signature, 3, 100).is_empty());
|
||||
assert!(blocks(b"-----BEGIN PGP SIGNATURE-----\n\nSGVsbG8=\n", Kind::Signature, 3, 100).is_empty());
|
||||
assert!(blocks(b"-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n", Kind::Signature, 3, 100).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleartext_undoes_dash_escaping_and_trailing_white_space() {
|
||||
let text = b"Intro, not signed\r\n-----BEGIN PGP SIGNED MESSAGE-----\r\nHash: SHA256\r\n\r\nline one \r\n- -dashes\r\n\r\nlast\t\r\n-----BEGIN PGP SIGNATURE-----\r\n\r\nSGVsbG8=\r\n-----END PGP SIGNATURE-----\r\ntrailer";
|
||||
let clear = cleartext(text, 100).unwrap();
|
||||
assert_eq!(clear.signed, b"line one\r\n-dashes\r\n\r\nlast");
|
||||
assert_eq!(clear.signature, b"Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incomplete_cleartext_is_none() {
|
||||
assert!(cleartext(b"-----BEGIN PGP SIGNED MESSAGE-----\n\ntext\n", 100).is_none());
|
||||
assert!(cleartext(b"-----BEGIN PGP SIGNED MESSAGE-----\n\ntext\n-----BEGIN PGP SIGNATURE-----\n\nSGVsbG8=\n", 100).is_none());
|
||||
assert!(cleartext(b"plain text", 100).is_none());
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The `Autocrypt:` header (Autocrypt Level 1, §2.1): a sender's Public key carried in every
|
||||
//! message, for the address it came from. Read, never acted on here: the caller keeps the key
|
||||
//! for that address, and nothing is sent.
|
||||
|
||||
use crate::keys::{Budget, MAX_CHECKS_PER_CALL, MAX_KEY_BYTES, PublicKey};
|
||||
use crate::{Error, mime};
|
||||
|
||||
/// How long a header value may be: a key for Autocrypt is meant to be small (Level 1 §2.1
|
||||
/// recommends one signing and one encryption key, a few KB), and the cap leaves room.
|
||||
pub(crate) const MAX_HEADER: usize = 64 * 1024;
|
||||
|
||||
/// An `Autocrypt:` header that reads: whose it is, what they prefer, and their key.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Autocrypt {
|
||||
/// The address it is for (`addr`), lower case.
|
||||
pub address: String,
|
||||
/// `prefer-encrypt=mutual`: the sender wants encrypted replies when both sides can.
|
||||
pub prefer_encrypt_mutual: bool,
|
||||
/// The key (`keydata`), its self-signatures checked.
|
||||
pub key: PublicKey,
|
||||
}
|
||||
|
||||
/// Reads one `Autocrypt:` header value. [`Error::Unreadable`] when it lacks `addr` or `keydata`,
|
||||
/// has an attribute it doesn't know that doesn't start with `_` (which Level 1 says makes the
|
||||
/// whole header one to ignore), or carries no key that checks; [`Error::TooLarge`] when it is
|
||||
/// longer than a key for Autocrypt needs.
|
||||
pub fn parse_autocrypt(value: &str) -> Result<Autocrypt, Error> {
|
||||
attributes(value)?.read(&mut Budget::new(MAX_CHECKS_PER_CALL))
|
||||
}
|
||||
|
||||
/// A header's attributes, read before its key is: the key is the costly part.
|
||||
pub(crate) struct Attributes<'a> {
|
||||
pub(crate) address: String,
|
||||
mutual: bool,
|
||||
keydata: &'a str,
|
||||
}
|
||||
|
||||
/// The attributes of an `Autocrypt:` header value, as [`parse_autocrypt`] reads them.
|
||||
pub(crate) fn attributes(value: &str) -> Result<Attributes<'_>, Error> {
|
||||
if value.len() > MAX_HEADER {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
let (mut address, mut keydata, mut mutual) = (None, None, false);
|
||||
for attribute in value.split(';') {
|
||||
let attribute = attribute.trim();
|
||||
if attribute.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (name, data) = attribute.split_once('=').ok_or(Error::Unreadable)?;
|
||||
match name.trim() {
|
||||
"addr" if address.is_none() => address = Some(mime::address(data.trim())),
|
||||
"keydata" if keydata.is_none() => keydata = Some(data),
|
||||
"prefer-encrypt" => mutual = data.trim() == "mutual",
|
||||
name if name.starts_with('_') => {}
|
||||
_ => return Err(Error::Unreadable),
|
||||
}
|
||||
}
|
||||
let address = address.filter(|a| !a.is_empty()).ok_or(Error::Unreadable)?;
|
||||
Ok(Attributes { address, mutual, keydata: keydata.ok_or(Error::Unreadable)? })
|
||||
}
|
||||
|
||||
impl Attributes<'_> {
|
||||
/// The header with its key read.
|
||||
pub(crate) fn read(self, total: &mut Budget) -> Result<Autocrypt, Error> {
|
||||
let bytes = mime::base64(self.keydata.as_bytes());
|
||||
if bytes.len() > MAX_KEY_BYTES {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
// Level 1 §2.1: the keydata is one Transferable Public Key.
|
||||
let key = PublicKey::binary(&bytes, 1, total).into_iter().next().ok_or(Error::Unreadable)?;
|
||||
Ok(Autocrypt { address: self.address, prefer_encrypt_mutual: self.mutual, key })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use crate::autocrypt::MAX_HEADER;
|
||||
use crate::{Error, parse_autocrypt};
|
||||
|
||||
#[test]
|
||||
fn a_header_without_addr_or_keydata_is_unreadable() {
|
||||
assert_eq!(parse_autocrypt("keydata=AAAA").unwrap_err(), Error::Unreadable);
|
||||
assert_eq!(parse_autocrypt("addr=a@example.org").unwrap_err(), Error::Unreadable);
|
||||
assert_eq!(parse_autocrypt("").unwrap_err(), Error::Unreadable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_critical_attribute_makes_the_header_unreadable() {
|
||||
assert_eq!(parse_autocrypt("addr=a@example.org; colour=blue; keydata=AAAA").unwrap_err(), Error::Unreadable);
|
||||
assert_eq!(parse_autocrypt("addr=a@example.org; novalue; keydata=AAAA").unwrap_err(), Error::Unreadable);
|
||||
// One starting with `_` is skipped; the keydata here is no key, so it still doesn't read.
|
||||
assert_eq!(parse_autocrypt("addr=a@example.org; _colour=blue; keydata=AAAA").unwrap_err(), Error::Unreadable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_too_long_is_refused_before_it_is_read() {
|
||||
let long = format!("addr=a@example.org; keydata={}", "A".repeat(MAX_HEADER));
|
||||
assert_eq!(parse_autocrypt(&long).unwrap_err(), Error::TooLarge);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! Checking one Digital signature over bytes, and what came of it.
|
||||
//!
|
||||
//! Only what a signature's hashed area says is believed: its issuer there may be blamed for a
|
||||
//! signature that doesn't match, and its unhashed area (which anybody can change) is a hint to
|
||||
//! find a key that then has to fit, never a reason for a result. What was signed is hashed once
|
||||
//! for each hash, kind and salt among the signatures, whatever the number of keys tried.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use pgp::composed::{Deserializable, DetachedSignature};
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::public_key::PublicKeyAlgorithm;
|
||||
use pgp::line_writer::LineBreak;
|
||||
use pgp::normalize_lines::NormalizedReader;
|
||||
use pgp::packet::{Signature, SignatureConfig, SignatureType, SignatureVersion, SignatureVersionSpecific, Subpacket, SubpacketData};
|
||||
use pgp::types::{KeyVersion, SignatureBytes, VerifyingKey};
|
||||
use sha2::digest::{Digest, DynDigest};
|
||||
|
||||
use crate::keys::{self, Fingerprint, KeyId, KeyRing, PublicKey, Signing};
|
||||
|
||||
/// How many signatures over one text are looked at.
|
||||
pub(crate) const MAX_SIGNATURES: usize = 8;
|
||||
/// How many bytes a block of signatures may hold, decoded.
|
||||
pub(crate) const MAX_SIGNATURE_BYTES: usize = 64 * 1024;
|
||||
/// How many keys one signature is tried with.
|
||||
pub(crate) const MAX_CANDIDATES: usize = 8;
|
||||
/// How many signature checks (public-key operations) one message may cost.
|
||||
pub(crate) const MAX_VERIFICATIONS: usize = 24;
|
||||
|
||||
/// Who made a Digital signature and when, for the details.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Signer {
|
||||
/// The primary key's fingerprint: what the owner compares and marks Verified.
|
||||
pub fingerprint: Fingerprint,
|
||||
/// The fingerprint of the key that signed, when it is a subkey.
|
||||
pub subkey: Option<Fingerprint>,
|
||||
/// The key id of the key that signed.
|
||||
pub key_id: KeyId,
|
||||
/// The key's addresses (from its own User IDs), lower case.
|
||||
pub addresses: Vec<String>,
|
||||
/// When the signature says it was made, in seconds since 1970.
|
||||
pub signed: u64,
|
||||
/// When the primary key was made, in seconds since 1970.
|
||||
pub key_created: u64,
|
||||
/// The signing key's algorithm ("RSA", "Ed25519", …).
|
||||
pub algorithm: &'static str,
|
||||
/// The signature's hash ("SHA-256", …).
|
||||
pub hash: &'static str,
|
||||
}
|
||||
|
||||
/// Who a signature that couldn't be checked names as its signer in its hashed area, if it does.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Issuer {
|
||||
pub fingerprint: Option<Fingerprint>,
|
||||
pub key_id: Option<KeyId>,
|
||||
}
|
||||
|
||||
/// Why a valid Digital signature isn't Verified: one or more of these.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct NotVerified {
|
||||
/// The owner hasn't Verified this key for the From address (marked it after comparing the
|
||||
/// fingerprint, or their GnuPG keyring trusts it fully).
|
||||
pub key_unverified: bool,
|
||||
/// The From address isn't one of the key's own (when the message was signed).
|
||||
pub address_differs: bool,
|
||||
/// The signature covers only part of the message ([`Coverage`]).
|
||||
pub partly_signed: bool,
|
||||
}
|
||||
|
||||
/// Why a Digital signature can't be checked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CantCheck {
|
||||
/// No Public key here for the key it names, nor any for the From address.
|
||||
NoPublicKey,
|
||||
/// None of the Public keys here for the From address made it, and the key it names (if
|
||||
/// any) isn't here.
|
||||
NoMatchingKey,
|
||||
/// Made with an algorithm that is no longer safe: a hash (MD5, SHA-1, RIPEMD-160) or a key
|
||||
/// (DSA, RSA under 2048 bits). Its name.
|
||||
WeakAlgorithm(&'static str),
|
||||
/// An algorithm or signature version this program doesn't know (or one before 4).
|
||||
UnknownAlgorithm,
|
||||
/// The signing key (or its primary key) was revoked by its owner, for this signature.
|
||||
KeyRevoked,
|
||||
/// The signing key (or its primary key) had expired when the signature was made.
|
||||
KeyExpired,
|
||||
/// The key that matches isn't one that may sign (or its subkey didn't sign back that it
|
||||
/// belongs to the key).
|
||||
NotASigningKey,
|
||||
/// The signature itself has expired.
|
||||
SignatureExpired,
|
||||
/// The message is larger than any looked at: it is signed, but isn't checked.
|
||||
TooLarge,
|
||||
/// The signature, or what it is over, can't be read, or is malformed.
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
/// What came of checking a message's OpenPGP Digital signature: the four results.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// Valid, the key Verified for the From address, which is the key's, over the whole message.
|
||||
Verified(Signer),
|
||||
/// Valid, but not Verified: why.
|
||||
Valid { signer: Signer, why: NotVerified },
|
||||
/// The message was changed after it was signed: the key its hashed area names is here and
|
||||
/// doesn't match it.
|
||||
Changed(Signer),
|
||||
/// It can't be checked, and why; who its hashed area names as its signer.
|
||||
CantCheck { why: CantCheck, issuer: Issuer },
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
/// Which outcome to show of several signatures over one text: the best.
|
||||
fn rank(&self) -> u8 {
|
||||
match self {
|
||||
Outcome::Verified(_) => 3,
|
||||
Outcome::Valid { .. } => 2,
|
||||
Outcome::Changed(_) => 1,
|
||||
Outcome::CantCheck { .. } => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cant(why: CantCheck) -> Outcome {
|
||||
Outcome::CantCheck { why, issuer: Issuer::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// What of a message a Digital signature covers.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Coverage {
|
||||
/// Whether it is the whole message: a PGP/MIME message at the top, or a plain text body that
|
||||
/// is one cleartext-signed block with nothing but white space around it. Only then can a
|
||||
/// message be Verified.
|
||||
pub whole: bool,
|
||||
/// The MIME part signed, as the place of each part on the way down from the message (empty:
|
||||
/// the message itself; `[0]`: its first part).
|
||||
pub path: Vec<usize>,
|
||||
/// For a cleartext-signed text: where the block is in that part's text (decoded from its
|
||||
/// transfer encoding), from its first line to the end of its last.
|
||||
pub span: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// A message's Digital signature checked: what came of it, and what it covers.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Checked {
|
||||
pub outcome: Outcome,
|
||||
pub coverage: Coverage,
|
||||
}
|
||||
|
||||
/// What the caller hands in to check against, and what checking has cost so far.
|
||||
pub(crate) struct Context<'a> {
|
||||
/// The From address, lower case; empty when the From field doesn't hold exactly one.
|
||||
pub(crate) from: String,
|
||||
pub(crate) keys: &'a KeyRing,
|
||||
pub(crate) now: u64,
|
||||
pub(crate) verified: &'a dyn Fn(&Fingerprint, &str) -> bool,
|
||||
/// Public-key operations made.
|
||||
pub(crate) verifications: Cell<usize>,
|
||||
/// Times what was signed was hashed.
|
||||
pub(crate) data_passes: Cell<usize>,
|
||||
}
|
||||
|
||||
/// A hash over what was signed, before a signature's own fields: cloned for each signature.
|
||||
#[derive(Clone)]
|
||||
enum Base {
|
||||
Sha224(sha2::Sha224),
|
||||
Sha256(sha2::Sha256),
|
||||
Sha384(sha2::Sha384),
|
||||
Sha512(sha2::Sha512),
|
||||
Sha3_256(sha3::Sha3_256),
|
||||
Sha3_512(sha3::Sha3_512),
|
||||
}
|
||||
|
||||
impl Base {
|
||||
fn new(hash: HashAlgorithm) -> Option<Base> {
|
||||
Some(match hash {
|
||||
HashAlgorithm::Sha224 => Base::Sha224(Default::default()),
|
||||
HashAlgorithm::Sha256 => Base::Sha256(Default::default()),
|
||||
HashAlgorithm::Sha384 => Base::Sha384(Default::default()),
|
||||
HashAlgorithm::Sha512 => Base::Sha512(Default::default()),
|
||||
HashAlgorithm::Sha3_256 => Base::Sha3_256(Default::default()),
|
||||
HashAlgorithm::Sha3_512 => Base::Sha3_512(Default::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn update(&mut self, bytes: &[u8]) {
|
||||
match self {
|
||||
Base::Sha224(h) => Digest::update(h, bytes),
|
||||
Base::Sha256(h) => Digest::update(h, bytes),
|
||||
Base::Sha384(h) => Digest::update(h, bytes),
|
||||
Base::Sha512(h) => Digest::update(h, bytes),
|
||||
Base::Sha3_256(h) => Digest::update(h, bytes),
|
||||
Base::Sha3_512(h) => Digest::update(h, bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed(&self) -> Box<dyn DynDigest + Send> {
|
||||
match self.clone() {
|
||||
Base::Sha224(h) => Box::new(h),
|
||||
Base::Sha256(h) => Box::new(h),
|
||||
Base::Sha384(h) => Box::new(h),
|
||||
Base::Sha512(h) => Box::new(h),
|
||||
Base::Sha3_256(h) => Box::new(h),
|
||||
Base::Sha3_512(h) => Box::new(h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hashes over what was signed, one for each hash, kind (binary or text) and salt.
|
||||
type Bases = Vec<((HashAlgorithm, bool, Vec<u8>), Base)>;
|
||||
|
||||
/// Checks the signature packets `packets` (binary) over `data`; the best outcome of them.
|
||||
pub(crate) fn check(packets: &[u8], data: &[u8], cx: &Context<'_>) -> Outcome {
|
||||
if packets.len() > MAX_SIGNATURE_BYTES {
|
||||
return Outcome::cant(CantCheck::Unreadable);
|
||||
}
|
||||
let parsed = crate::guard(|| DetachedSignature::from_bytes_many(packets).map(|it| it.take(MAX_SIGNATURES).collect::<Vec<_>>()));
|
||||
let Some(Ok(signatures)) = parsed else { return Outcome::cant(CantCheck::Unreadable) };
|
||||
let mut bases: Bases = Vec::new();
|
||||
let mut best: Option<Outcome> = None;
|
||||
for signature in signatures.into_iter().filter_map(Result::ok) {
|
||||
let outcome = crate::guard(|| one(&signature.signature, data, cx, &mut bases)).unwrap_or_else(|| Outcome::cant(CantCheck::Unreadable));
|
||||
if best.as_ref().is_none_or(|b| outcome.rank() > b.rank()) {
|
||||
best = Some(outcome);
|
||||
}
|
||||
}
|
||||
best.unwrap_or_else(|| Outcome::cant(CantCheck::Unreadable))
|
||||
}
|
||||
|
||||
/// The issuers a list of subpackets names.
|
||||
fn issuers(subpackets: &[Subpacket]) -> (Vec<Fingerprint>, Vec<KeyId>) {
|
||||
let mut fps = Vec::new();
|
||||
let mut ids = Vec::new();
|
||||
for p in subpackets.iter().take(64) {
|
||||
match &p.data {
|
||||
SubpacketData::IssuerFingerprint(fp) => fps.push(Fingerprint::of(fp)),
|
||||
SubpacketData::IssuerKeyId(id) => ids.push(KeyId::of(id)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(fps, ids)
|
||||
}
|
||||
|
||||
/// The digest a signature signs: what was signed (hashed once per hash, kind and salt), then
|
||||
/// its own fields and trailer (RFC 9580 §5.2.4). `None` when it can't be made.
|
||||
fn digest(config: &SignatureConfig, data: &[u8], cx: &Context<'_>, bases: &mut Bases) -> Option<Vec<u8>> {
|
||||
let text = config.typ == SignatureType::Text;
|
||||
let salt = match &config.version_specific {
|
||||
SignatureVersionSpecific::V6 { salt } => {
|
||||
if config.hash_alg.salt_len() != Some(salt.len()) {
|
||||
return None;
|
||||
}
|
||||
salt.clone()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let key = (config.hash_alg, text, salt);
|
||||
let base = match bases.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, base)) => base.clone(),
|
||||
None => {
|
||||
let mut base = Base::new(config.hash_alg)?;
|
||||
base.update(&key.2);
|
||||
if text {
|
||||
// RFC 9580 §5.2.1.2: a text signature is over the text with CR LF line ends.
|
||||
let mut reader = NormalizedReader::new(data, LineBreak::Crlf);
|
||||
let mut chunk = [0u8; 8192];
|
||||
loop {
|
||||
let n = std::io::Read::read(&mut reader, &mut chunk).ok()?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
base.update(chunk.get(..n)?);
|
||||
}
|
||||
} else {
|
||||
base.update(data);
|
||||
}
|
||||
cx.data_passes.set(cx.data_passes.get().saturating_add(1));
|
||||
bases.push((key, base.clone()));
|
||||
base
|
||||
}
|
||||
};
|
||||
let mut hasher = base.boxed();
|
||||
let len = config.hash_signature_data(&mut hasher).ok()?;
|
||||
hasher.update(&config.trailer(len).ok()?);
|
||||
Some(hasher.finalize().to_vec())
|
||||
}
|
||||
|
||||
fn one(sig: &Signature, data: &[u8], cx: &Context<'_>, bases: &mut Bases) -> Outcome {
|
||||
let Some(config) = sig.config() else { return Outcome::cant(CantCheck::UnknownAlgorithm) };
|
||||
let (fps, ids) = issuers(&config.hashed_subpackets);
|
||||
let (hint_fps, hint_ids) = issuers(&config.unhashed_subpackets);
|
||||
let issuer = Issuer { fingerprint: fps.first().cloned(), key_id: ids.first().copied() };
|
||||
let cant = |why: CantCheck| Outcome::CantCheck { why, issuer: issuer.clone() };
|
||||
// Version 3 signatures (and earlier) hash too little of themselves to be trusted.
|
||||
if !matches!(config.version(), SignatureVersion::V4 | SignatureVersion::V6) {
|
||||
return cant(CantCheck::UnknownAlgorithm);
|
||||
}
|
||||
if !matches!(config.typ, SignatureType::Binary | SignatureType::Text) {
|
||||
return cant(CantCheck::Unreadable);
|
||||
}
|
||||
if keys::is_weak(config.hash_alg) {
|
||||
return cant(CantCheck::WeakAlgorithm(keys::hash_name(config.hash_alg)));
|
||||
}
|
||||
if config.pub_alg == PublicKeyAlgorithm::DSA {
|
||||
return cant(CantCheck::WeakAlgorithm("DSA"));
|
||||
}
|
||||
if !known_signature_algorithm(config.pub_alg) {
|
||||
return cant(CantCheck::UnknownAlgorithm);
|
||||
}
|
||||
// RFC 9580 §5.2.3.11: a signature says when it was made, in its hashed area.
|
||||
let Some(signed) = config.created().map(|t| u64::from(t.as_secs())) else { return cant(CantCheck::Unreadable) };
|
||||
// A signature whose value isn't of the algorithm's shape is malformed, never "changed".
|
||||
let Some(sig_bytes) = sig.signature().filter(|b| well_shaped(config.pub_alg, b)) else { return cant(CantCheck::Unreadable) };
|
||||
let Some(digest) = digest(config, data, cx, bases) else { return cant(CantCheck::UnknownAlgorithm) };
|
||||
// The first two bytes of the digest are in the signature: a quick test that it is over this.
|
||||
let fits = sig.signed_hash_value().is_some_and(|v| digest.get(..2) == Some(&v[..]));
|
||||
|
||||
// The keys to try, at most MAX_CANDIDATES: those the hashed area names; then those the
|
||||
// unhashed area names (a hint: only a key that fits counts), before any other key of the
|
||||
// From address, so that keys claiming the address can't push the signer's out; then the
|
||||
// From address's keys.
|
||||
let mut candidates: Vec<(&PublicKey, &Signing, bool)> = Vec::new();
|
||||
for (k, s) in cx.keys.signers(&fps, &ids) {
|
||||
push(&mut candidates, k, s, true);
|
||||
}
|
||||
for (k, s) in cx.keys.signers(&hint_fps, &hint_ids) {
|
||||
push(&mut candidates, k, s, false);
|
||||
}
|
||||
let from_keys: Vec<&PublicKey> = cx.keys.for_address(&cx.from).collect();
|
||||
for k in &from_keys {
|
||||
for s in &k.signing {
|
||||
push(&mut candidates, k, s, false);
|
||||
}
|
||||
}
|
||||
|
||||
let mut blamed: Option<Signer> = None;
|
||||
let mut weak: Option<&'static str> = None;
|
||||
let mut starved = false;
|
||||
for (key, signing, named) in candidates {
|
||||
// A version 6 signature is made by a version 6 key and only by one (RFC 9580 §5.2.3).
|
||||
if (signing.version == KeyVersion::V6) != (config.version() == SignatureVersion::V6) || signing.algorithm != config.pub_alg {
|
||||
continue;
|
||||
}
|
||||
let signer = Signer {
|
||||
fingerprint: key.fingerprint().clone(),
|
||||
subkey: signing.subkey.map(|_| signing.fingerprint.clone()),
|
||||
key_id: signing.key_id,
|
||||
addresses: key.addresses().to_vec(),
|
||||
signed,
|
||||
key_created: key.created(),
|
||||
algorithm: keys::algorithm_name(signing.algorithm),
|
||||
hash: keys::hash_name(config.hash_alg),
|
||||
};
|
||||
if let Some(why) = signing.weak {
|
||||
if named {
|
||||
weak = Some(why);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !fits {
|
||||
if named {
|
||||
blamed.get_or_insert(signer);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if cx.verifications.get() >= MAX_VERIFICATIONS {
|
||||
// Keys left untried: what came of it isn't known.
|
||||
starved = true;
|
||||
break;
|
||||
}
|
||||
cx.verifications.set(cx.verifications.get().saturating_add(1));
|
||||
if verify_with(config.hash_alg, &digest, sig_bytes, key, signing) {
|
||||
return judged(sig, key, signing, signer, cx).unwrap_or_else(&cant);
|
||||
}
|
||||
if named {
|
||||
blamed.get_or_insert(signer);
|
||||
}
|
||||
}
|
||||
if let Some(why) = weak {
|
||||
return cant(CantCheck::WeakAlgorithm(why));
|
||||
}
|
||||
match blamed {
|
||||
Some(signer) => Outcome::Changed(signer),
|
||||
None if starved => cant(CantCheck::Unreadable),
|
||||
None if !from_keys.is_empty() => cant(CantCheck::NoMatchingKey),
|
||||
None => cant(CantCheck::NoPublicKey),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a key to try, once, while there is room.
|
||||
fn push<'a>(list: &mut Vec<(&'a PublicKey, &'a Signing, bool)>, key: &'a PublicKey, signing: &'a Signing, named: bool) {
|
||||
if list.len() < MAX_CANDIDATES && !list.iter().any(|(_, s, _)| s.fingerprint == signing.fingerprint) {
|
||||
list.push((key, signing, named));
|
||||
}
|
||||
}
|
||||
|
||||
/// A signature that checks: whether its key (and the primary key over it) could make it then,
|
||||
/// and whether it is Verified.
|
||||
fn judged(sig: &Signature, key: &PublicKey, signing: &Signing, signer: Signer, cx: &Context<'_>) -> Result<Outcome, CantCheck> {
|
||||
let when = signer.signed;
|
||||
let primary = key.primary();
|
||||
if signing.revoked.is_some_and(|r| r.covers(Some(when))) || primary.and_then(|p| p.revoked).is_some_and(|r| r.covers(Some(when))) {
|
||||
return Err(CantCheck::KeyRevoked);
|
||||
}
|
||||
if !signing.may_sign {
|
||||
return Err(CantCheck::NotASigningKey);
|
||||
}
|
||||
// A signature that says it was made before its key was is not one the key made.
|
||||
if when < u64::from(signing.created) {
|
||||
return Err(CantCheck::Unreadable);
|
||||
}
|
||||
if signing.expired_at(when) || primary.is_some_and(|p| p.expired_at(when)) {
|
||||
return Err(CantCheck::KeyExpired);
|
||||
}
|
||||
if let Some(after) = sig.signature_expiration_time().map(|d| u64::from(d.as_secs()))
|
||||
&& after > 0
|
||||
&& cx.now >= when.saturating_add(after)
|
||||
{
|
||||
return Err(CantCheck::SignatureExpired);
|
||||
}
|
||||
let address_on_key = !cx.from.is_empty() && key.has_address_at(&cx.from, when);
|
||||
let why = NotVerified {
|
||||
key_unverified: cx.from.is_empty() || !(cx.verified)(&signer.fingerprint, &cx.from),
|
||||
address_differs: !address_on_key,
|
||||
partly_signed: false,
|
||||
};
|
||||
Ok(if why.key_unverified || why.address_differs { Outcome::Valid { signer, why } } else { Outcome::Verified(signer) })
|
||||
}
|
||||
|
||||
fn verify_with(hash: HashAlgorithm, digest: &[u8], sig: &SignatureBytes, key: &PublicKey, signing: &Signing) -> bool {
|
||||
let inner = key.inner();
|
||||
match signing.subkey {
|
||||
None => inner.primary_key.verify(hash, digest, sig).is_ok(),
|
||||
Some(i) => inner.public_subkeys.get(i).is_some_and(|sub| sub.key.verify(hash, digest, sig).is_ok()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a signature value has the shape its algorithm gives it (RFC 9580 §5.2.3): one MPI
|
||||
/// for RSA, two for ECDSA and legacy EdDSA, 64 or 114 bytes for Ed25519 and Ed448.
|
||||
fn well_shaped(algorithm: PublicKeyAlgorithm, bytes: &SignatureBytes) -> bool {
|
||||
match (algorithm, bytes) {
|
||||
(PublicKeyAlgorithm::RSA | PublicKeyAlgorithm::RSASign, SignatureBytes::Mpis(m)) => m.len() == 1,
|
||||
(PublicKeyAlgorithm::ECDSA | PublicKeyAlgorithm::EdDSALegacy, SignatureBytes::Mpis(m)) => m.len() == 2,
|
||||
(PublicKeyAlgorithm::Ed25519, SignatureBytes::Native(b)) => b.len() == 64,
|
||||
(PublicKeyAlgorithm::Ed448, SignatureBytes::Native(b)) => b.len() == 114,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Algorithms a Digital signature can be made with that are checked (DSA is refused before).
|
||||
fn known_signature_algorithm(algorithm: PublicKeyAlgorithm) -> bool {
|
||||
matches!(
|
||||
algorithm,
|
||||
PublicKeyAlgorithm::RSA
|
||||
| PublicKeyAlgorithm::RSASign
|
||||
| PublicKeyAlgorithm::ECDSA
|
||||
| PublicKeyAlgorithm::EdDSALegacy
|
||||
| PublicKeyAlgorithm::Ed25519
|
||||
| PublicKeyAlgorithm::Ed448
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! What a signature covers: only a whole message can be Verified, and words around a signed
|
||||
//! part never borrow its signature.
|
||||
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
|
||||
use crate::fixtures::*;
|
||||
use crate::{CantCheck, Checked, Coverage, Fingerprint, KeyRing, MAX_MESSAGE, NotVerified, Outcome, verify};
|
||||
|
||||
const ALICE: &str = "Alice <alice@example.org>";
|
||||
|
||||
fn yes(_: &Fingerprint, _: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn alice() -> (pgp::composed::SignedSecretKey, KeyRing) {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let ring = [public(&alice)].into_iter().collect();
|
||||
(alice, ring)
|
||||
}
|
||||
|
||||
/// A PGP/MIME message by `alice` as a part: its Content-Type field and its body.
|
||||
fn signed_part(alice: &pgp::composed::SignedSecretKey) -> (String, String) {
|
||||
let inner = String::from_utf8(signed_message(alice, HashAlgorithm::Sha256)).unwrap();
|
||||
let (head, body) = inner.split_once("\r\n\r\n").unwrap();
|
||||
let content_type = head.split("\r\n").skip_while(|l| !l.starts_with("Content-Type")).collect::<Vec<_>>().join("\r\n");
|
||||
(content_type, body.to_string())
|
||||
}
|
||||
|
||||
fn partly(checked: &Checked) -> bool {
|
||||
matches!(&checked.outcome, Outcome::Valid { why: NotVerified { partly_signed: true, .. }, .. })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pgp_mime_message_at_the_top_covers_the_whole_message() {
|
||||
let (alice, keys) = alice();
|
||||
let checked = verify(&signed_message(&alice, HashAlgorithm::Sha256), ALICE, &keys, LATER, yes).unwrap();
|
||||
assert_eq!(checked.coverage, Coverage { whole: true, path: vec![], span: None });
|
||||
assert!(matches!(checked.outcome, Outcome::Verified(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_alternative_beside_an_unsigned_one_is_not_a_signed_message() {
|
||||
// The HTML alternative is what most readers show, and it isn't signed.
|
||||
let (alice, keys) = alice();
|
||||
let (content_type, body) = signed_part(&alice);
|
||||
let message = format!(
|
||||
"From: {ALICE}\r\nContent-Type: multipart/alternative; boundary=\"alt\"\r\n\r\n--alt\r\n{content_type}\r\n\r\n{body}\r\n--alt\r\nContent-Type: text/html\r\n\r\n<p>Wire 10000 EUR to Mallory</p>\r\n--alt--\r\n"
|
||||
);
|
||||
assert_eq!(verify(message.as_bytes(), ALICE, &keys, LATER, yes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_part_beside_an_unsigned_one_is_only_partly_signed() {
|
||||
let (alice, keys) = alice();
|
||||
let (content_type, body) = signed_part(&alice);
|
||||
let message = format!(
|
||||
"From: {ALICE}\r\nContent-Type: multipart/mixed; boundary=\"mix\"\r\n\r\n--mix\r\n{content_type}\r\n\r\n{body}\r\n--mix\r\nContent-Type: text/plain\r\n\r\nWire 10000 EUR to Mallory\r\n--mix--\r\n"
|
||||
);
|
||||
let checked = verify(message.as_bytes(), ALICE, &keys, LATER, yes).unwrap();
|
||||
assert!(!checked.coverage.whole);
|
||||
assert_eq!(checked.coverage.path, vec![0]);
|
||||
assert!(partly(&checked), "{checked:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_signed_body_alone_covers_the_whole_message_and_says_where() {
|
||||
let (alice, keys) = alice();
|
||||
let block = cleartext(&alice, "Pay 10 EUR to Bob.\n");
|
||||
let body = format!("\r\n{block}\r\n\r\n");
|
||||
let checked = verify(&plain(ALICE, "7bit", &body), ALICE, &keys, LATER, yes).unwrap();
|
||||
assert!(matches!(checked.outcome, Outcome::Verified(_)), "{checked:?}");
|
||||
assert!(checked.coverage.whole);
|
||||
let (start, end) = checked.coverage.span.unwrap();
|
||||
assert_eq!(&body[start..end].trim_end(), &block.trim_end());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_before_or_after_an_inline_signed_block_are_not_covered() {
|
||||
let (alice, keys) = alice();
|
||||
let block = cleartext(&alice, "Pay 10 EUR to Bob.\n");
|
||||
for body in [format!("Wire 10000 EUR to Mallory\r\n{block}"), format!("{block}\r\nPS: wire 10000 EUR to Mallory\r\n")] {
|
||||
let checked = verify(&plain(ALICE, "7bit", &body), ALICE, &keys, LATER, yes).unwrap();
|
||||
assert!(!checked.coverage.whole);
|
||||
assert!(partly(&checked), "{checked:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_inline_signed_blocks_are_each_only_part() {
|
||||
let (alice, keys) = alice();
|
||||
let body = format!("{}\r\n{}", cleartext(&alice, "First.\n"), cleartext(&alice, "Second.\n"));
|
||||
let checked = verify(&plain(ALICE, "7bit", &body), ALICE, &keys, LATER, yes).unwrap();
|
||||
assert!(partly(&checked), "{checked:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_armour_header_other_than_hash_or_an_unescaped_dash_line_is_unreadable() {
|
||||
// Both would show words in the block that aren't signed.
|
||||
let (alice, keys) = alice();
|
||||
let block = cleartext(&alice, "Pay 10 EUR to Bob.\n");
|
||||
let header = block.replacen("-----BEGIN PGP SIGNED MESSAGE-----\n", "-----BEGIN PGP SIGNED MESSAGE-----\nNote: and 10000 EUR to Mallory\n", 1);
|
||||
assert_ne!(header, block);
|
||||
let dash = block.replacen("Pay 10 EUR to Bob.", "-----BEGIN PGP SIGNATURE-----", 1);
|
||||
// A `Hash:` header with more than hash names after it.
|
||||
let with_hash = block.replacen(
|
||||
"-----BEGIN PGP SIGNED MESSAGE-----\n",
|
||||
"-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256, and pay Mallory 10000 EUR today\n",
|
||||
1,
|
||||
);
|
||||
assert_ne!(with_hash, block);
|
||||
// Hash names alone, as many writers give them, are fine.
|
||||
let names = block.replacen("-----BEGIN PGP SIGNED MESSAGE-----\n", "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256, sha512 ,SHA3-256\n", 1);
|
||||
let checked = verify(&plain(ALICE, "7bit", &names), ALICE, &keys, LATER, yes).unwrap();
|
||||
assert!(matches!(checked.outcome, Outcome::Verified(_)), "{checked:?}");
|
||||
for body in [header, dash, with_hash] {
|
||||
let checked = verify(&plain(ALICE, "7bit", &body), ALICE, &keys, LATER, yes).unwrap();
|
||||
assert!(matches!(checked.outcome, Outcome::CantCheck { why: CantCheck::Unreadable, .. }), "{checked:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_message_too_large_to_check_says_so() {
|
||||
let mut big = signed_message(&secret(ALICE, Make::default()), HashAlgorithm::Sha256);
|
||||
big.resize(MAX_MESSAGE + 1, b'a');
|
||||
let checked = verify(&big, ALICE, &KeyRing::new(), LATER, yes).unwrap();
|
||||
assert!(matches!(checked.outcome, Outcome::CantCheck { why: CantCheck::TooLarge, .. }));
|
||||
// Inline-signed, the block found anywhere in it.
|
||||
let mut big = plain(ALICE, "7bit", &"a".repeat(1 << 20));
|
||||
big.extend_from_slice(b"\r\n-----BEGIN PGP SIGNED MESSAGE-----\r\n");
|
||||
big.resize(MAX_MESSAGE + 1, b'a');
|
||||
assert!(verify(&big, ALICE, &KeyRing::new(), LATER, yes).is_some());
|
||||
// Not signed at all: nothing to say.
|
||||
let mut big = plain(ALICE, "7bit", "Just text.\r\n");
|
||||
big.resize(MAX_MESSAGE + 1, b'a');
|
||||
assert_eq!(verify(&big, ALICE, &KeyRing::new(), LATER, yes), None);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Keys, signatures and messages made in the tests: synthetic `example.org` identities only.
|
||||
|
||||
use pgp::composed::{
|
||||
ArmorOptions, CleartextSignedMessage, DetachedSignature, EncryptionCaps, KeyType, SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey,
|
||||
SubkeyParamsBuilder,
|
||||
};
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::{KeyDetails, KeyVersion, Password, SigningKey, Timestamp};
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::StdRng;
|
||||
|
||||
use crate::PublicKey;
|
||||
|
||||
/// RFC 9580 Appendix A.3: the sample version 6 certificate (no User ID).
|
||||
pub(crate) const RFC9580_A3_CERT: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
xioGY4d/4xsAAAAg+U2nu0jWCmHlZ3BqZYfQMxmZu52JGggkLq2EVD34laPCsQYf
|
||||
GwoAAABCBYJjh3/jAwsJBwUVCg4IDAIWAAKbAwIeCSIhBssYbE8GCaaX5NUt+mxy
|
||||
KwwfHifBilZwj2Ul7Ce62azJBScJAgcCAAAAAK0oIBA+LX0ifsDm185Ecds2v8lw
|
||||
gyU2kCcUmKfvBXbAf6rhRYWzuQOwEn7E/aLwIwRaLsdry0+VcallHhSu4RN6HWaE
|
||||
QsiPlR4zxP/TP7mhfVEe7XWPxtnMUMtf15OyA51YBM4qBmOHf+MZAAAAIIaTJINn
|
||||
+eUBXbki+PSAld2nhJh/LVmFsS+60WyvXkQ1wpsGGBsKAAAALAWCY4d/4wKbDCIh
|
||||
BssYbE8GCaaX5NUt+mxyKwwfHifBilZwj2Ul7Ce62azJAAAAAAQBIKbpGG2dWTX8
|
||||
j+VjFM21J0hqWlEg+bdiojWnKfA5AQpWUWtnNwDEM0g12vYxoWM8Y81W+bHBw805
|
||||
I8kWVkXU6vFOi+HWvv/ira7ofJu16NnoUkhclkUrk0mXubZvyl4GBg==
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
";
|
||||
|
||||
/// RFC 9580 Appendix A.6: the sample cleartext-signed message, signed by [`RFC9580_A3_CERT`].
|
||||
pub(crate) const RFC9580_A6_CLEARTEXT: &str = "-----BEGIN PGP SIGNED MESSAGE-----
|
||||
|
||||
What we need from the grocery store:
|
||||
|
||||
- - tofu
|
||||
- - vegetables
|
||||
- - noodles
|
||||
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
wpgGARsKAAAAKQWCY5ijYyIhBssYbE8GCaaX5NUt+mxyKwwfHifBilZwj2Ul7Ce6
|
||||
2azJAAAAAGk2IHZJX1AhiJD39eLuPBgiUU9wUA9VHYblySHkBONKU/usJ9BvuAqo
|
||||
/FvLFuGWMbKAdA+epq7V4HOtAPlBWmU8QOd6aud+aSunHQaaEJ+iTFjP2OMW0KBr
|
||||
NK2ay45cX1IVAQ==
|
||||
-----END PGP SIGNATURE-----
|
||||
";
|
||||
|
||||
/// The primary fingerprint of [`RFC9580_A3_CERT`].
|
||||
pub(crate) const RFC9580_A3_FINGERPRINT: &str = "CB186C4F0609A697E4D52DFA6C722B0C1F1E27C18A56708F6525EC27BAD9ACC9";
|
||||
|
||||
/// A key for the property tests to look signatures up in.
|
||||
pub(crate) static ALICE_FOR_PROPS: std::sync::LazyLock<Vec<PublicKey>> =
|
||||
std::sync::LazyLock::new(|| vec![public(&secret("A <a@example.org>", Make::default()))]);
|
||||
|
||||
/// A time after every signature made in the tests (2100-01-01).
|
||||
pub(crate) const LATER: u64 = 4_102_444_800;
|
||||
|
||||
/// How a test key is made.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Make {
|
||||
pub(crate) seed: u64,
|
||||
/// A subkey signs, not the primary key.
|
||||
pub(crate) subkey: bool,
|
||||
pub(crate) v6: bool,
|
||||
/// RSA of this many bits rather than EdDSA: rPGP makes MD5 and SHA-1 signatures only with RSA.
|
||||
pub(crate) rsa: Option<usize>,
|
||||
/// A second User ID, `Alias <alias@example.org>`.
|
||||
pub(crate) alias: bool,
|
||||
/// An RSA encryption subkey of 2048 bits besides.
|
||||
pub(crate) rsa_encryption: bool,
|
||||
}
|
||||
|
||||
impl Default for Make {
|
||||
fn default() -> Make {
|
||||
Make { seed: 1, subkey: false, v6: false, rsa: None, alias: false, rsa_encryption: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// A secret key for `user_id` (an `example.org` identity).
|
||||
pub(crate) fn secret(user_id: &str, make: Make) -> SignedSecretKey {
|
||||
let rng = StdRng::seed_from_u64(make.seed);
|
||||
let version = if make.v6 { KeyVersion::V6 } else { KeyVersion::V4 };
|
||||
let key_type = match (make.rsa, make.v6) {
|
||||
(Some(bits), _) => KeyType::Rsa(bits as u32),
|
||||
(None, true) => KeyType::Ed25519,
|
||||
(None, false) => KeyType::Ed25519Legacy,
|
||||
};
|
||||
let mut params = SecretKeyParamsBuilder::default();
|
||||
params.version(version).key_type(key_type.clone()).can_certify(true).can_sign(!make.subkey).primary_user_id(user_id.into());
|
||||
if make.alias {
|
||||
params.user_ids(vec!["Alias <alias@example.org>".into()]);
|
||||
}
|
||||
let mut subkeys = Vec::new();
|
||||
if make.subkey {
|
||||
let mut sub = SubkeyParamsBuilder::default();
|
||||
sub.version(version).key_type(key_type).can_sign(true).can_encrypt(EncryptionCaps::None);
|
||||
subkeys.push(sub.build().unwrap());
|
||||
}
|
||||
if make.rsa_encryption {
|
||||
let mut sub = SubkeyParamsBuilder::default();
|
||||
sub.version(version).key_type(KeyType::Rsa(2048)).can_sign(false).can_encrypt(EncryptionCaps::All);
|
||||
subkeys.push(sub.build().unwrap());
|
||||
}
|
||||
if !subkeys.is_empty() {
|
||||
params.subkeys(subkeys);
|
||||
}
|
||||
params.build().unwrap().generate(rng).unwrap()
|
||||
}
|
||||
|
||||
/// The public half of `secret`, read back as the crate reads keys.
|
||||
pub(crate) fn public(secret: &SignedSecretKey) -> PublicKey {
|
||||
let bytes = SignedPublicKey::from(secret.clone()).to_bytes().unwrap();
|
||||
PublicKey::parse(&bytes).unwrap().remove(0)
|
||||
}
|
||||
|
||||
/// The armoured public half of `secret`.
|
||||
pub(crate) fn armoured_public(secret: &SignedSecretKey) -> String {
|
||||
SignedPublicKey::from(secret.clone()).to_armored_string(ArmorOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// The key that signs: rPGP takes either kind through this box alone.
|
||||
#[allow(clippy::redundant_allocation)]
|
||||
pub(crate) fn signer(secret: &SignedSecretKey) -> Box<&dyn SigningKey> {
|
||||
match secret.secret_subkeys.first() {
|
||||
Some(sub) => Box::new(&sub.key),
|
||||
None => Box::new(&secret.primary_key),
|
||||
}
|
||||
}
|
||||
|
||||
/// An armoured detached signature over `data` by `secret` (its signing subkey when it has one).
|
||||
pub(crate) fn detached(secret: &SignedSecretKey, data: &[u8], hash: HashAlgorithm, text: bool) -> String {
|
||||
let rng = StdRng::seed_from_u64(7);
|
||||
let key = signer(secret);
|
||||
let sig = if text {
|
||||
DetachedSignature::sign_text_data(rng, &key, &Password::empty(), hash, data).unwrap()
|
||||
} else {
|
||||
DetachedSignature::sign_binary_data(rng, &key, &Password::empty(), hash, data).unwrap()
|
||||
};
|
||||
sig.to_armored_string(ArmorOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// When `secret`'s primary key was made, in seconds since 1970.
|
||||
pub(crate) fn made(secret: &SignedSecretKey) -> u32 {
|
||||
secret.primary_key.created_at().as_secs()
|
||||
}
|
||||
|
||||
/// A signature of `typ` by `key` made at `at`, its issuer's fingerprint and `extra` in its
|
||||
/// hashed area: to be signed.
|
||||
pub(crate) fn config(key: &impl SigningKey, typ: SignatureType, at: u32, extra: Vec<SubpacketData>) -> SignatureConfig {
|
||||
let mut config = SignatureConfig::from_key(StdRng::seed_from_u64(11), key, typ).unwrap();
|
||||
config.hashed_subpackets = [SubpacketData::SignatureCreationTime(Timestamp::from_secs(at)), SubpacketData::IssuerFingerprint(key.fingerprint())]
|
||||
.into_iter()
|
||||
.chain(extra)
|
||||
.map(|d| Subpacket::regular(d).unwrap())
|
||||
.collect();
|
||||
config
|
||||
}
|
||||
|
||||
/// A PGP/MIME message from alice@example.org over [`SIGNED_PART`], signed by `secret`'s
|
||||
/// signing key at `at`.
|
||||
pub(crate) fn signed_at(secret: &SignedSecretKey, at: u32) -> Vec<u8> {
|
||||
let key = signer(secret);
|
||||
let sig = config(&key, SignatureType::Binary, at, vec![]).sign(&key, &Password::empty(), SIGNED_PART.as_bytes()).unwrap();
|
||||
pgp_mime("alice@example.org", SIGNED_PART, &DetachedSignature::new(sig).to_armored_string(ArmorOptions::default()).unwrap())
|
||||
}
|
||||
|
||||
/// The keys in `key`, read as the crate reads them, as a ring.
|
||||
pub(crate) fn ring_of(key: &SignedPublicKey) -> crate::KeyRing {
|
||||
PublicKey::parse(&key.to_bytes().unwrap()).unwrap().into_iter().collect()
|
||||
}
|
||||
|
||||
/// A cleartext-signed `text` by `secret`.
|
||||
pub(crate) fn cleartext(secret: &SignedSecretKey, text: &str) -> String {
|
||||
let rng = StdRng::seed_from_u64(9);
|
||||
let key = signer(secret);
|
||||
CleartextSignedMessage::sign(rng, text, &key, &Password::empty()).unwrap().to_armored_string(ArmorOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// The part a PGP/MIME message signs, CR LF throughout.
|
||||
pub(crate) const SIGNED_PART: &str = "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello Bob,\r\n\r\nthe minutes are attached.\r\n-- \r\nAlice\r\n";
|
||||
|
||||
/// A PGP/MIME message from `from` whose first part is `part` and whose signature is `armour`.
|
||||
pub(crate) fn pgp_mime(from: &str, part: &str, armour: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: Alice <{from}>\r\nTo: bob@example.org\r\nSubject: Minutes\r\nMIME-Version: 1.0\r\nContent-Type: multipart/signed; micalg=pgp-sha256;\r\n protocol=\"application/pgp-signature\"; boundary=\"=-b1\"\r\n\r\nThis is an OpenPGP/MIME signed message.\r\n--=-b1\r\n{part}\r\n--=-b1\r\nContent-Type: application/pgp-signature; name=\"signature.asc\"\r\nContent-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n{armour}\r\n--=-b1--\r\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// A PGP/MIME message from alice@example.org signed by `secret` over [`SIGNED_PART`].
|
||||
pub(crate) fn signed_message(secret: &SignedSecretKey, hash: HashAlgorithm) -> Vec<u8> {
|
||||
let armour = detached(secret, SIGNED_PART.as_bytes(), hash, false);
|
||||
pgp_mime("alice@example.org", SIGNED_PART, &armour)
|
||||
}
|
||||
|
||||
/// A one-part message from `from` with `body` as its text, `encoding` its transfer encoding.
|
||||
pub(crate) fn plain(from: &str, encoding: &str, body: &str) -> Vec<u8> {
|
||||
format!("From: {from}\r\nSubject: Note\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: {encoding}\r\n\r\n{body}")
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// `bytes` in base64, a line break after every 76 characters.
|
||||
pub(crate) fn base64(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
for (n, chunk) in bytes.chunks(3).enumerate() {
|
||||
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
||||
let v = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
for i in 0..4 {
|
||||
if i <= chunk.len() {
|
||||
out.push(ALPHABET[((v >> (18 - 6 * i)) & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
if n % 19 == 18 {
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
//! Public keys: read from their bytes (armoured or not), their self-signatures checked once
|
||||
//! when they are read, and kept in a [`KeyRing`] that finds them by fingerprint, key id or
|
||||
//! address. A key's addresses are those of the User IDs it signed itself (RFC 9580 §5.2.1,
|
||||
//! §10.1); nothing here is a web of trust: whether a key is Verified is the caller's to say.
|
||||
|
||||
use pgp::composed::{Deserializable, SignedKeyDetails, SignedPublicKey, SignedPublicSubKey};
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::public_key::PublicKeyAlgorithm;
|
||||
use pgp::packet::{KeyFlags, RevocationCode, Signature, SignatureType, SignatureVersion, SubpacketData};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::{KeyDetails, KeyVersion, PublicParams, SignedUser, Tag};
|
||||
|
||||
use crate::armour::{self, Kind};
|
||||
use crate::{Error, mime};
|
||||
|
||||
/// How many bytes a block of Public keys may hold, decoded.
|
||||
pub(crate) const MAX_KEY_BYTES: usize = 1 << 20;
|
||||
/// How many keys one block (a file, an attachment) is read for.
|
||||
pub(crate) const MAX_KEYS_PER_BLOCK: usize = 16;
|
||||
/// How many User IDs and subkeys one key may have: more is not a key anybody sends.
|
||||
const MAX_USERS: usize = 64;
|
||||
const MAX_SUBKEYS: usize = 32;
|
||||
/// How long a User ID may be: a name and an address are far shorter. (User attributes, such as
|
||||
/// pictures, are never checked or kept, so cost nothing.)
|
||||
const MAX_USER_ID: usize = 4 * 1024;
|
||||
/// How many signature checks reading one key may cost (third-party certifications are never
|
||||
/// checked, so a real key needs a handful). Room for two copies of the largest key kept, merged:
|
||||
/// two self-signatures for each User ID, and two with a back-signature each for each subkey.
|
||||
const MAX_CHECKS: usize = 512;
|
||||
/// How many packets one block is read for before it is given up on.
|
||||
const MAX_PACKETS: usize = 4_096;
|
||||
|
||||
/// A key's fingerprint (RFC 9580 §5.5.4): 20 bytes for a version 4 key, 32 for version 6.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Fingerprint(Vec<u8>);
|
||||
|
||||
impl Fingerprint {
|
||||
/// The fingerprint written as hexadecimal digits (spaces and a `0x` in front allowed).
|
||||
pub fn from_hex(text: &str) -> Option<Fingerprint> {
|
||||
let text = text.trim();
|
||||
let text = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")).unwrap_or(text);
|
||||
let digits: Vec<u32> = text.chars().filter(|c| !c.is_whitespace()).map(|c| c.to_digit(16)).collect::<Option<_>>()?;
|
||||
if !matches!(digits.len(), 40 | 64) {
|
||||
return None;
|
||||
}
|
||||
Some(Fingerprint(digits.chunks(2).map(|p| p.iter().fold(0u8, |acc, d| (acc << 4) | (*d as u8))).collect()))
|
||||
}
|
||||
|
||||
/// The fingerprint's bytes.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Upper-case hexadecimal, no spaces: what [`Fingerprint::from_hex`] reads back.
|
||||
pub fn to_hex(&self) -> String {
|
||||
self.0.iter().map(|b| format!("{b:02X}")).collect()
|
||||
}
|
||||
|
||||
/// Upper-case hexadecimal in groups of four, for an owner to compare by eye.
|
||||
pub fn grouped(&self) -> String {
|
||||
let hex = self.to_hex();
|
||||
let groups: Vec<&str> = (0..hex.len()).step_by(4).filter_map(|i| hex.get(i..i.saturating_add(4).min(hex.len()))).collect();
|
||||
groups.join(" ")
|
||||
}
|
||||
|
||||
pub(crate) fn of(fp: &pgp::types::Fingerprint) -> Fingerprint {
|
||||
Fingerprint(fp.as_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Fingerprint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
/// A key id (RFC 9580 §5.5.4): eight bytes that a signature names its signer by.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct KeyId(pub [u8; 8]);
|
||||
|
||||
impl KeyId {
|
||||
/// Upper-case hexadecimal, sixteen digits.
|
||||
pub fn to_hex(&self) -> String {
|
||||
self.0.iter().map(|b| format!("{b:02X}")).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn of(id: &pgp::types::KeyId) -> KeyId {
|
||||
let mut bytes = [0u8; 8];
|
||||
for (to, from) in bytes.iter_mut().zip(id.as_ref()) {
|
||||
*to = *from;
|
||||
}
|
||||
KeyId(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KeyId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
/// A public-key algorithm's name, as an owner may see it in the details.
|
||||
pub(crate) fn algorithm_name(algorithm: PublicKeyAlgorithm) -> &'static str {
|
||||
match algorithm {
|
||||
PublicKeyAlgorithm::RSA | PublicKeyAlgorithm::RSASign | PublicKeyAlgorithm::RSAEncrypt => "RSA",
|
||||
PublicKeyAlgorithm::DSA => "DSA",
|
||||
PublicKeyAlgorithm::ECDSA => "ECDSA",
|
||||
PublicKeyAlgorithm::EdDSALegacy => "EdDSA",
|
||||
PublicKeyAlgorithm::Ed25519 => "Ed25519",
|
||||
PublicKeyAlgorithm::Ed448 => "Ed448",
|
||||
PublicKeyAlgorithm::ECDH => "ECDH",
|
||||
PublicKeyAlgorithm::X25519 => "X25519",
|
||||
PublicKeyAlgorithm::X448 => "X448",
|
||||
PublicKeyAlgorithm::Elgamal | PublicKeyAlgorithm::ElgamalEncrypt => "Elgamal",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// A hash algorithm's name, as an owner may see it in the details.
|
||||
pub(crate) fn hash_name(hash: HashAlgorithm) -> &'static str {
|
||||
match hash {
|
||||
HashAlgorithm::Md5 => "MD5",
|
||||
HashAlgorithm::Sha1 => "SHA-1",
|
||||
HashAlgorithm::Ripemd160 => "RIPEMD-160",
|
||||
HashAlgorithm::Sha224 => "SHA-224",
|
||||
HashAlgorithm::Sha256 => "SHA-256",
|
||||
HashAlgorithm::Sha384 => "SHA-384",
|
||||
HashAlgorithm::Sha512 => "SHA-512",
|
||||
HashAlgorithm::Sha3_256 => "SHA3-256",
|
||||
HashAlgorithm::Sha3_512 => "SHA3-512",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Hashes no longer safe for a signature: MD5, SHA-1 and RIPEMD-160 (RFC 9580 §9.5).
|
||||
pub(crate) fn is_weak(hash: HashAlgorithm) -> bool {
|
||||
matches!(hash, HashAlgorithm::Md5 | HashAlgorithm::Sha1 | HashAlgorithm::Ripemd160)
|
||||
}
|
||||
|
||||
/// Why a key may not be trusted to make a Digital signature at all, whatever its signature
|
||||
/// says: DSA, and RSA under 2048 bits (RFC 9580 §12.4, §12.5).
|
||||
pub(crate) fn weak_key(key: &impl KeyDetails) -> Option<&'static str> {
|
||||
match key.public_params() {
|
||||
PublicParams::DSA(_) => Some("DSA"),
|
||||
PublicParams::RSA(params) => {
|
||||
// An MPI starts with its length in bits (RFC 9580 §3.2): n comes first.
|
||||
let bytes = params.to_bytes().unwrap_or_default();
|
||||
let bits = u16::from_be_bytes([bytes.first().copied().unwrap_or(0), bytes.get(1).copied().unwrap_or(0)]);
|
||||
(bits < 2048).then_some("RSA under 2048 bits")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A revocation of a key or subkey (RFC 9580 §5.2.3.31): a hard one (compromised, or no reason
|
||||
/// a reader knows) undoes every signature the key made; a soft one (superseded, retired) only
|
||||
/// those made at or after it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Revoked {
|
||||
pub(crate) hard: bool,
|
||||
pub(crate) at: u64,
|
||||
}
|
||||
|
||||
impl Revoked {
|
||||
fn of(sig: &Signature) -> Revoked {
|
||||
let soft = matches!(sig.revocation_reason_code(), Some(RevocationCode::KeySuperseded | RevocationCode::KeyRetired));
|
||||
Revoked { hard: !soft, at: u64::from(created(sig)) }
|
||||
}
|
||||
|
||||
/// Whether it undoes a signature made at `when` (`None`: unknown, so yes).
|
||||
pub(crate) fn covers(&self, when: Option<u64>) -> bool {
|
||||
self.hard || when.is_none_or(|t| t >= self.at)
|
||||
}
|
||||
}
|
||||
|
||||
/// One key of a [`PublicKey`] that may make Digital signatures: the primary key or a subkey.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Signing {
|
||||
/// `None` for the primary key, else the subkey's place in the key.
|
||||
pub(crate) subkey: Option<usize>,
|
||||
pub(crate) fingerprint: Fingerprint,
|
||||
pub(crate) key_id: KeyId,
|
||||
pub(crate) version: KeyVersion,
|
||||
pub(crate) algorithm: PublicKeyAlgorithm,
|
||||
pub(crate) created: u32,
|
||||
/// When it stops being valid, in seconds since 1970 (its own expiry, or that of the
|
||||
/// self-signature that binds it); `None` when never.
|
||||
pub(crate) expires: Option<u64>,
|
||||
/// Its own revocation; a subkey is also undone by the primary key's.
|
||||
pub(crate) revoked: Option<Revoked>,
|
||||
/// Whether its self-signature lets it sign (subkeys say so; a primary key that says nothing
|
||||
/// may, as RFC 9580 §5.2.3.29 leaves it).
|
||||
pub(crate) may_sign: bool,
|
||||
/// Why it isn't strong enough to be trusted, if it isn't.
|
||||
pub(crate) weak: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Signing {
|
||||
/// Whether it was valid at `when`: not before it was made, not after it expired.
|
||||
pub(crate) fn expired_at(&self, when: u64) -> bool {
|
||||
self.expires.is_some_and(|end| when >= end)
|
||||
}
|
||||
}
|
||||
|
||||
/// A Public key (an OpenPGP certificate, RFC 9580 §10.1) whose self-signatures check.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PublicKey {
|
||||
key: SignedPublicKey,
|
||||
fingerprint: Fingerprint,
|
||||
addresses: Vec<String>,
|
||||
/// For each address, when its User ID's self-signature stops being valid.
|
||||
address_until: Vec<Option<u64>>,
|
||||
/// The primary key first, then each subkey that may sign.
|
||||
pub(crate) signing: Vec<Signing>,
|
||||
}
|
||||
|
||||
/// How many signature checks one call that reads keys may cost in all ([`PublicKey::parse`],
|
||||
/// [`crate::keys_in_message`]).
|
||||
pub(crate) const MAX_CHECKS_PER_CALL: usize = 1_024;
|
||||
/// How many revocations of one key are checked: never cut short; a key with more is refused.
|
||||
/// Room for two of each part of two merged copies of the largest key kept.
|
||||
const MAX_REVOCATION_CHECKS: usize = 256;
|
||||
|
||||
/// How many signature checks some reading may cost.
|
||||
pub(crate) struct Budget {
|
||||
left: usize,
|
||||
/// A check was wanted and refused.
|
||||
pub(crate) starved: bool,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub(crate) fn new(left: usize) -> Budget {
|
||||
Budget { left, starved: false }
|
||||
}
|
||||
|
||||
/// Whether one more check may be made, counting it.
|
||||
fn spend(&mut self) -> bool {
|
||||
if self.left == 0 {
|
||||
self.starved = true;
|
||||
return false;
|
||||
}
|
||||
self.left -= 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl PublicKey {
|
||||
/// Every Public key in `bytes`: armoured (`-----BEGIN PGP PUBLIC KEY BLOCK-----`, as many
|
||||
/// blocks as there are) or binary, at most 16 in all. A key whose self-signatures don't
|
||||
/// check, of a version before 4, or larger than a key anybody sends, is left out.
|
||||
/// [`Error::Unreadable`] when no key at all could be read.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Vec<PublicKey>, Error> {
|
||||
Self::parse_with(bytes, &mut Budget::new(MAX_CHECKS_PER_CALL))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_with(bytes: &[u8], total: &mut Budget) -> Result<Vec<PublicKey>, Error> {
|
||||
let mut out = Vec::new();
|
||||
let blocks = armour::blocks(bytes, Kind::PublicKey, MAX_KEYS_PER_BLOCK, MAX_KEY_BYTES);
|
||||
if blocks.is_empty() {
|
||||
if bytes.len() > MAX_KEY_BYTES {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
out.extend(Self::binary(bytes, MAX_KEYS_PER_BLOCK, total));
|
||||
} else {
|
||||
for block in blocks {
|
||||
let room = MAX_KEYS_PER_BLOCK.saturating_sub(out.len());
|
||||
if room == 0 {
|
||||
break;
|
||||
}
|
||||
out.extend(Self::binary(&block, room, total));
|
||||
}
|
||||
}
|
||||
if out.is_empty() { Err(Error::Unreadable) } else { Ok(out) }
|
||||
}
|
||||
|
||||
/// At most `room` keys in binary packets.
|
||||
pub(crate) fn binary(bytes: &[u8], room: usize, total: &mut Budget) -> Vec<PublicKey> {
|
||||
if room == 0 || count_packets(bytes) > MAX_PACKETS {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(Ok(keys)) = crate::guard(|| SignedPublicKey::from_bytes_many(bytes).map(|it| it.take(room).collect::<Vec<_>>())) else {
|
||||
return Vec::new();
|
||||
};
|
||||
keys.into_iter().filter_map(Result::ok).filter_map(|key| crate::guard(|| PublicKey::checked(key, total)).flatten()).collect()
|
||||
}
|
||||
|
||||
/// The key, once its self-signatures check: its addresses from the User IDs it certified,
|
||||
/// its revocations, and the keys of it that may sign. `None` when it is no key to trust:
|
||||
/// nothing certifies it, it is too large, its version is before 4, or a budget ran out
|
||||
/// before all of it was checked. What is kept of it is what checked and is used: for the
|
||||
/// key, each User ID and each subkey, its newest self-signature and its strongest
|
||||
/// revocation; nothing that anybody but the key's owner could have added.
|
||||
pub(crate) fn checked(key: SignedPublicKey, total: &mut Budget) -> Option<PublicKey> {
|
||||
if !matches!(key.primary_key.version(), KeyVersion::V4 | KeyVersion::V6)
|
||||
|| key.details.users.len() > MAX_USERS
|
||||
|| key.public_subkeys.len() > MAX_SUBKEYS
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let primary = &key.primary_key;
|
||||
let mut local = Budget::new(MAX_CHECKS);
|
||||
let mut revocations = Budget::new(MAX_REVOCATION_CHECKS);
|
||||
let spend = |budget: &mut Budget, total: &mut Budget| budget.spend() && total.spend();
|
||||
|
||||
// Revocations first, on a budget of their own, so that none is ever left unchecked.
|
||||
let mut key_revocation: Option<&Signature> = None;
|
||||
for sig in key.details.revocation_signatures.iter().chain(&key.details.direct_signatures) {
|
||||
if sig.typ() == Some(SignatureType::KeyRevocation)
|
||||
&& usable(sig)
|
||||
&& is_self(sig, primary)
|
||||
&& spend(&mut revocations, total)
|
||||
&& sig.verify_key(primary).is_ok()
|
||||
{
|
||||
key_revocation = stronger(key_revocation, sig);
|
||||
}
|
||||
}
|
||||
|
||||
// The newest valid self-signature on the key or one of its User IDs: its key flags and
|
||||
// expiry are the primary key's (RFC 9580 §5.2.3.10, §10.1.1).
|
||||
let mut newest: Option<&Signature> = None;
|
||||
let mut direct: Option<&Signature> = None;
|
||||
for sig in &key.details.direct_signatures {
|
||||
if sig.typ() == Some(SignatureType::Key)
|
||||
&& usable(sig)
|
||||
&& is_self(sig, primary)
|
||||
&& spend(&mut local, total)
|
||||
&& sig.verify_key(primary).is_ok()
|
||||
{
|
||||
consider(sig, &mut direct);
|
||||
}
|
||||
}
|
||||
if let Some(sig) = direct {
|
||||
consider(sig, &mut newest);
|
||||
}
|
||||
let (mut addresses, mut address_until, mut users) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for user in &key.details.users {
|
||||
// Every check of a User ID hashes all of it: a longer one than anybody's is left
|
||||
// out with its signatures, unhashed.
|
||||
if user.id.id().len() > MAX_USER_ID {
|
||||
continue;
|
||||
}
|
||||
let mut certified: Option<&Signature> = None;
|
||||
let mut user_revocation: Option<&Signature> = None;
|
||||
for sig in &user.signatures {
|
||||
let Some(typ) = sig.typ() else { continue };
|
||||
if !usable(sig) || !is_self(sig, primary) {
|
||||
continue;
|
||||
}
|
||||
if typ == SignatureType::CertRevocation {
|
||||
if spend(&mut revocations, total) && sig.verify_certification(primary, Tag::UserId, &user.id).is_ok() {
|
||||
consider(sig, &mut user_revocation);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let certification =
|
||||
matches!(typ, SignatureType::CertGeneric | SignatureType::CertPersona | SignatureType::CertCasual | SignatureType::CertPositive);
|
||||
if certification && spend(&mut local, total) && sig.verify_certification(primary, Tag::UserId, &user.id).is_ok() {
|
||||
consider(sig, &mut certified);
|
||||
}
|
||||
}
|
||||
if certified.is_some() || user_revocation.is_some() {
|
||||
users.push(SignedUser::new(user.id.clone(), certified.into_iter().chain(user_revocation).cloned().collect()));
|
||||
}
|
||||
let Some(cert) = certified else { continue };
|
||||
consider(cert, &mut newest);
|
||||
// RFC 9580 §5.2.3.31: the newest self-signature on a User ID is what holds, so a
|
||||
// certification after its revocation takes it back (one at the same second doesn't).
|
||||
let revoked = user_revocation.is_some_and(|r| created(r) >= created(cert));
|
||||
let address = mime::address(&String::from_utf8_lossy(user.id.id()));
|
||||
if !revoked && !address.is_empty() && !addresses.contains(&address) {
|
||||
addresses.push(address);
|
||||
address_until.push(signature_until(cert));
|
||||
}
|
||||
}
|
||||
let newest = newest?;
|
||||
let primary_created = primary.created_at().as_secs();
|
||||
let mut signing = vec![Signing {
|
||||
subkey: None,
|
||||
fingerprint: Fingerprint::of(&primary.fingerprint()),
|
||||
key_id: KeyId::of(&primary.legacy_key_id()),
|
||||
version: primary.version(),
|
||||
algorithm: primary.algorithm(),
|
||||
created: primary_created,
|
||||
expires: earlier(expiry(primary_created, newest), signature_until(newest)),
|
||||
revoked: key_revocation.map(Revoked::of),
|
||||
may_sign: flags(newest).is_none_or(|f| f.sign()),
|
||||
weak: weak_key(primary),
|
||||
}];
|
||||
let mut subkeys = Vec::new();
|
||||
for sub in &key.public_subkeys {
|
||||
if !matches!(sub.key.version(), KeyVersion::V4 | KeyVersion::V6) {
|
||||
continue;
|
||||
}
|
||||
let mut binding: Option<&Signature> = None;
|
||||
let mut sub_revocation: Option<&Signature> = None;
|
||||
for sig in &sub.signatures {
|
||||
if !usable(sig) || !is_self(sig, primary) {
|
||||
continue;
|
||||
}
|
||||
match sig.typ() {
|
||||
Some(SignatureType::SubkeyRevocation) => {
|
||||
if spend(&mut revocations, total) && sig.verify_subkey_binding(primary, &sub.key).is_ok() {
|
||||
sub_revocation = stronger(sub_revocation, sig);
|
||||
}
|
||||
}
|
||||
Some(SignatureType::SubkeyBinding) if spend(&mut local, total) && sig.verify_subkey_binding(primary, &sub.key).is_ok() => {
|
||||
consider(sig, &mut binding);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(binding) = binding else { continue };
|
||||
let place = subkeys.len();
|
||||
subkeys.push(SignedPublicSubKey::new(sub.key.clone(), std::iter::once(binding).chain(sub_revocation).cloned().collect()));
|
||||
if !flags(binding).is_some_and(|f| f.sign()) {
|
||||
continue;
|
||||
}
|
||||
// A subkey that signs must sign back that it belongs to this key (§5.2.1): else
|
||||
// anybody's key could claim somebody else's subkey.
|
||||
let backed = binding
|
||||
.embedded_signature()
|
||||
.is_some_and(|back| spend(&mut local, total) && back.verify_primary_key_binding(&sub.key, primary).is_ok());
|
||||
let sub_created = sub.key.created_at().as_secs();
|
||||
signing.push(Signing {
|
||||
subkey: Some(place),
|
||||
fingerprint: Fingerprint::of(&sub.key.fingerprint()),
|
||||
key_id: KeyId::of(&sub.key.legacy_key_id()),
|
||||
version: sub.key.version(),
|
||||
algorithm: sub.key.algorithm(),
|
||||
created: sub_created,
|
||||
expires: earlier(expiry(sub_created, binding), signature_until(binding)),
|
||||
revoked: sub_revocation.map(Revoked::of),
|
||||
may_sign: backed,
|
||||
weak: weak_key(&sub.key),
|
||||
});
|
||||
}
|
||||
// A revocation that couldn't be checked, or a key not checked whole, is no key at all:
|
||||
// never a key with a part of it left out.
|
||||
if revocations.starved || local.starved || total.starved {
|
||||
return None;
|
||||
}
|
||||
let details = SignedKeyDetails::new(key_revocation.into_iter().cloned().collect(), direct.into_iter().cloned().collect(), users, Vec::new());
|
||||
let kept = SignedPublicKey::new(key.primary_key.clone(), details, subkeys);
|
||||
Some(PublicKey { fingerprint: Fingerprint::of(&key.fingerprint()), key: kept, addresses, address_until, signing })
|
||||
}
|
||||
|
||||
/// The primary key's fingerprint: what the owner compares, and marks Verified.
|
||||
pub fn fingerprint(&self) -> &Fingerprint {
|
||||
&self.fingerprint
|
||||
}
|
||||
|
||||
/// The primary key's key id.
|
||||
pub fn key_id(&self) -> KeyId {
|
||||
KeyId::of(&self.key.primary_key.legacy_key_id())
|
||||
}
|
||||
|
||||
/// The addresses of the User IDs the key certified itself (lower case), in its order.
|
||||
pub fn addresses(&self) -> &[String] {
|
||||
&self.addresses
|
||||
}
|
||||
|
||||
/// Whether `address` (an address, or a From field's value) is one of the key's own.
|
||||
pub fn has_address(&self, address: &str) -> bool {
|
||||
let address = mime::address(address);
|
||||
!address.is_empty() && self.addresses.contains(&address)
|
||||
}
|
||||
|
||||
/// Whether `address` was one of the key's own at `when`: its self-signature hadn't expired.
|
||||
pub(crate) fn has_address_at(&self, address: &str, when: u64) -> bool {
|
||||
let address = mime::address(address);
|
||||
!address.is_empty() && self.addresses.iter().zip(&self.address_until).any(|(a, until)| *a == address && until.is_none_or(|end| when < end))
|
||||
}
|
||||
|
||||
/// When the primary key was made, in seconds since 1970.
|
||||
pub fn created(&self) -> u64 {
|
||||
u64::from(self.key.primary_key.created_at().as_secs())
|
||||
}
|
||||
|
||||
/// The primary key's algorithm, by name ("RSA", "Ed25519", …).
|
||||
pub fn algorithm(&self) -> &'static str {
|
||||
algorithm_name(self.key.primary_key.algorithm())
|
||||
}
|
||||
|
||||
/// Whether the key's owner revoked it.
|
||||
pub fn revoked(&self) -> bool {
|
||||
self.primary().is_some_and(|s| s.revoked.is_some())
|
||||
}
|
||||
|
||||
/// When the primary key stops being valid, in seconds since 1970; `None` when never.
|
||||
pub fn expires(&self) -> Option<u64> {
|
||||
self.primary().and_then(|s| s.expires)
|
||||
}
|
||||
|
||||
pub(crate) fn primary(&self) -> Option<&Signing> {
|
||||
self.signing.first()
|
||||
}
|
||||
|
||||
/// The key as binary OpenPGP packets, to be kept and read back with [`PublicKey::parse`].
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
self.key.to_bytes().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn inner(&self) -> &SignedPublicKey {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
/// The earlier of two optional times.
|
||||
fn earlier(a: Option<u64>, b: Option<u64>) -> Option<u64> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// When a signature stops being valid by its own expiry (RFC 9580 §5.2.3.18).
|
||||
fn signature_until(sig: &Signature) -> Option<u64> {
|
||||
let after = sig.signature_expiration_time()?.as_secs();
|
||||
(after > 0).then(|| u64::from(created(sig)).saturating_add(u64::from(after)))
|
||||
}
|
||||
|
||||
/// Makes `sig` the `newest` when it is at least as new.
|
||||
fn consider<'a>(sig: &'a Signature, newest: &mut Option<&'a Signature>) {
|
||||
if newest.is_none_or(|n| created(sig) >= created(n)) {
|
||||
*newest = Some(sig);
|
||||
}
|
||||
}
|
||||
|
||||
/// A signature's creation time, 0 when it has none.
|
||||
fn created(sig: &Signature) -> u32 {
|
||||
sig.created().map_or(0, |t| t.as_secs())
|
||||
}
|
||||
|
||||
/// A self-signature with a hash that may still be trusted for one: everything but MD5 (SHA-1
|
||||
/// self-signatures are still on many keys, and rPGP checks SHA-1 for collision attacks), and
|
||||
/// of version 4 or 6.
|
||||
fn usable(sig: &Signature) -> bool {
|
||||
sig.hash_alg().is_some_and(|h| h != HashAlgorithm::Md5) && matches!(sig.version(), SignatureVersion::V4 | SignatureVersion::V6)
|
||||
}
|
||||
|
||||
/// Whether `sig` names `key` as its issuer, or names nobody (a hint only: the check decides).
|
||||
fn is_self(sig: &Signature, key: &impl KeyDetails) -> bool {
|
||||
let (ids, fps) = (sig.issuer_key_id(), sig.issuer_fingerprint());
|
||||
(ids.is_empty() && fps.is_empty()) || ids.iter().any(|id| **id == key.legacy_key_id()) || fps.iter().any(|fp| **fp == key.fingerprint())
|
||||
}
|
||||
|
||||
/// The key flags a self-signature states, if it states any.
|
||||
fn flags(sig: &Signature) -> Option<KeyFlags> {
|
||||
sig.config()?.hashed_subpackets.iter().find_map(|p| match &p.data {
|
||||
SubpacketData::KeyFlags(flags) => Some(flags.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// When a key made at `created` stops being valid by self-signature `sig`.
|
||||
fn expiry(created: u32, sig: &Signature) -> Option<u64> {
|
||||
let after = sig.key_expiration_time()?.as_secs();
|
||||
(after > 0).then(|| u64::from(created).saturating_add(u64::from(after)))
|
||||
}
|
||||
|
||||
/// How many packet headers `bytes` holds, counted without reading them (RFC 9580 §4.2): a
|
||||
/// cap on work for a block of many tiny packets.
|
||||
fn count_packets(bytes: &[u8]) -> usize {
|
||||
packet_tags(bytes, MAX_PACKETS.saturating_add(1)).len()
|
||||
}
|
||||
|
||||
/// The tags of the first `max` packets of `bytes` (RFC 9580 §4.2), read from their headers
|
||||
/// alone: as far as a header reads, and a packet of partial length as the last.
|
||||
pub(crate) fn packet_tags(bytes: &[u8], max: usize) -> Vec<u8> {
|
||||
packets(bytes, max).0
|
||||
}
|
||||
|
||||
/// [`packet_tags`], with where the packets read end: the length of `bytes` when they are all
|
||||
/// there is, short of it when something follows that isn't a packet (or a packet of partial or
|
||||
/// indeterminate length, or more than `max`), past it when the last says more than is there.
|
||||
pub(crate) fn packets(bytes: &[u8], max: usize) -> (Vec<u8>, usize) {
|
||||
let mut at = 0usize;
|
||||
let mut tags = Vec::new();
|
||||
while let Some(&tag) = bytes.get(at) {
|
||||
if tag & 0x80 == 0 || tags.len() >= max {
|
||||
break;
|
||||
}
|
||||
tags.push(if tag & 0x40 != 0 { tag & 0x3f } else { (tag >> 2) & 0x0f });
|
||||
let len = |o: usize| bytes.get(at.saturating_add(o)).copied().map_or(0usize, usize::from);
|
||||
let (header, body) = if tag & 0x40 != 0 {
|
||||
match len(1) {
|
||||
0..=191 => (2, len(1)),
|
||||
192..=223 => (3, ((len(1) - 192) << 8) + len(2) + 192),
|
||||
255 => (6, (len(2) << 24) | (len(3) << 16) | (len(4) << 8) | len(5)),
|
||||
// A partial length: the rest is one stream, counted as one.
|
||||
_ => break,
|
||||
}
|
||||
} else {
|
||||
match tag & 0x03 {
|
||||
0 => (2, len(1)),
|
||||
1 => (3, (len(1) << 8) | len(2)),
|
||||
2 => (5, (len(1) << 24) | (len(2) << 16) | (len(3) << 8) | len(4)),
|
||||
_ => break,
|
||||
}
|
||||
};
|
||||
at = at.saturating_add(header).saturating_add(body);
|
||||
}
|
||||
(tags, at)
|
||||
}
|
||||
|
||||
/// Of two revocations of one thing, the one that counts for more: a hard one (it undoes
|
||||
/// everything), else the earlier.
|
||||
fn stronger<'a>(a: Option<&'a Signature>, b: &'a Signature) -> Option<&'a Signature> {
|
||||
Some(match a {
|
||||
Some(a) if Revoked::of(a).hard => a,
|
||||
_ if Revoked::of(b).hard => b,
|
||||
Some(a) if created(a) <= created(b) => a,
|
||||
_ => b,
|
||||
})
|
||||
}
|
||||
|
||||
/// `a`, then what of `b` isn't in it: each signature once, `a`'s first. Both are what a check
|
||||
/// kept of a key (signatures its owner made, at most two of a kind for each part), so nothing
|
||||
/// is cut, and no date anybody wrote decides what stays.
|
||||
fn union(a: &[Signature], b: &[Signature]) -> Vec<Signature> {
|
||||
let mut all = a.to_vec();
|
||||
for sig in b {
|
||||
if !all.contains(sig) {
|
||||
all.push(sig.clone());
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// Two checked copies of one certificate as one (RFC 9580 §10.1): every User ID, subkey and
|
||||
/// self-signature of either, the old copy's first, so that a new copy can only add to it.
|
||||
fn merge(old: &SignedPublicKey, new: &SignedPublicKey) -> SignedPublicKey {
|
||||
let mut merged = old.clone();
|
||||
merged.details.revocation_signatures = union(&old.details.revocation_signatures, &new.details.revocation_signatures);
|
||||
merged.details.direct_signatures = union(&old.details.direct_signatures, &new.details.direct_signatures);
|
||||
for user in &new.details.users {
|
||||
let users = &mut merged.details.users;
|
||||
match users.iter().position(|u| u.id == user.id) {
|
||||
Some(i) => {
|
||||
if let Some(mine) = users.get_mut(i) {
|
||||
mine.signatures = union(&mine.signatures, &user.signatures);
|
||||
}
|
||||
}
|
||||
None if users.len() < MAX_USERS => users.push(user.clone()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
for sub in &new.public_subkeys {
|
||||
let fingerprint = sub.key.fingerprint();
|
||||
let subs = &mut merged.public_subkeys;
|
||||
match subs.iter().position(|s| s.key.fingerprint() == fingerprint) {
|
||||
Some(i) => {
|
||||
if let Some(mine) = subs.get_mut(i) {
|
||||
mine.signatures = union(&mine.signatures, &sub.signatures);
|
||||
}
|
||||
}
|
||||
None if subs.len() < MAX_SUBKEYS => subs.push(sub.clone()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// `key` with nothing but its revocations: what a merge must keep when all of it can't be.
|
||||
fn revocations_only(key: &SignedPublicKey) -> SignedPublicKey {
|
||||
let mut only = key.clone();
|
||||
only.details.direct_signatures.clear();
|
||||
only.details.users.clear();
|
||||
only.details.user_attributes.clear();
|
||||
for sub in &mut only.public_subkeys {
|
||||
sub.signatures.retain(|s| s.typ() == Some(SignatureType::SubkeyRevocation));
|
||||
}
|
||||
only
|
||||
}
|
||||
|
||||
/// The Public keys the caller holds: by address, fingerprint and key id. Linear look-ups; the
|
||||
/// caller decides how many keys it keeps.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct KeyRing {
|
||||
keys: Vec<PublicKey>,
|
||||
}
|
||||
|
||||
impl KeyRing {
|
||||
/// An empty ring.
|
||||
pub fn new() -> KeyRing {
|
||||
KeyRing::default()
|
||||
}
|
||||
|
||||
/// Adds `key`. A key already here with the same fingerprint is merged with it: every User
|
||||
/// ID, subkey and self-signature of both is kept and checked again, so that a copy that
|
||||
/// lacks a revocation never undoes it and a copy that brings one always counts. Whether
|
||||
/// the ring changed.
|
||||
pub fn insert(&mut self, key: PublicKey) -> bool {
|
||||
let Some(old) = self.keys.iter_mut().find(|k| k.fingerprint == key.fingerprint) else {
|
||||
self.keys.push(key);
|
||||
return true;
|
||||
};
|
||||
let recheck = |merged: SignedPublicKey| crate::guard(|| PublicKey::checked(merged, &mut Budget::new(MAX_CHECKS_PER_CALL))).flatten();
|
||||
let merged = recheck(merge(&old.key, &key.key)).or_else(|| recheck(merge(&old.key, &revocations_only(&key.key))));
|
||||
match merged {
|
||||
Some(merged) if merged.to_bytes() != old.to_bytes() => {
|
||||
*old = merged;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every key in the ring.
|
||||
pub fn keys(&self) -> &[PublicKey] {
|
||||
&self.keys
|
||||
}
|
||||
|
||||
/// The keys that have `address` (an address, or a From field's value) as one of their own.
|
||||
pub fn for_address<'a>(&'a self, address: &str) -> impl Iterator<Item = &'a PublicKey> + 'a {
|
||||
let address = mime::address(address);
|
||||
self.keys.iter().filter(move |k| !address.is_empty() && k.addresses.contains(&address))
|
||||
}
|
||||
|
||||
/// The key with primary fingerprint `fingerprint`.
|
||||
pub fn get(&self, fingerprint: &Fingerprint) -> Option<&PublicKey> {
|
||||
self.keys.iter().find(|k| &k.fingerprint == fingerprint)
|
||||
}
|
||||
|
||||
/// How many keys the ring holds.
|
||||
pub fn len(&self) -> usize {
|
||||
self.keys.len()
|
||||
}
|
||||
|
||||
/// Whether the ring holds no key.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.keys.is_empty()
|
||||
}
|
||||
|
||||
/// The signing keys (primary or subkey) named by fingerprint or key id.
|
||||
pub(crate) fn signers<'a>(&'a self, fps: &'a [Fingerprint], ids: &'a [KeyId]) -> impl Iterator<Item = (&'a PublicKey, &'a Signing)> + 'a {
|
||||
self.keys
|
||||
.iter()
|
||||
.flat_map(move |k| k.signing.iter().filter(move |s| fps.contains(&s.fingerprint) || ids.contains(&s.key_id)).map(move |s| (k, s)))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<PublicKey> for KeyRing {
|
||||
fn from_iter<I: IntoIterator<Item = PublicKey>>(iter: I) -> KeyRing {
|
||||
let mut ring = KeyRing::new();
|
||||
for key in iter {
|
||||
ring.insert(key);
|
||||
}
|
||||
ring
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Keys over time: revocations that are never lost, expiry of the primary key and of
|
||||
//! self-signatures, and the cost of reading keys.
|
||||
|
||||
use pgp::composed::{SignedPublicKey, SignedSecretKey};
|
||||
use pgp::packet::{RevocationCode, SignatureType, SubpacketData};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::{Duration, Password, Tag};
|
||||
|
||||
use crate::fixtures::*;
|
||||
use crate::keys::Budget;
|
||||
use crate::{CantCheck, Fingerprint, KeyRing, NotVerified, Outcome, PublicKey, verify};
|
||||
|
||||
const ALICE: &str = "Alice <alice@example.org>";
|
||||
|
||||
fn yes(_: &Fingerprint, _: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn outcome(message: &[u8], from: &str, keys: &KeyRing) -> Outcome {
|
||||
verify(message, from, keys, LATER, yes).unwrap().outcome
|
||||
}
|
||||
|
||||
fn revoked(outcome: &Outcome) -> bool {
|
||||
matches!(outcome, Outcome::CantCheck { why: CantCheck::KeyRevoked, .. })
|
||||
}
|
||||
|
||||
/// `secret`'s public key revoked at `at`, for `reason` (none: no reason given).
|
||||
fn key_revoked(secret: &SignedSecretKey, at: u32, reason: Option<RevocationCode>) -> SignedPublicKey {
|
||||
let mut key = SignedPublicKey::from(secret.clone());
|
||||
let extra = reason.map(|r| SubpacketData::RevocationReason(r, Vec::new().into())).into_iter().collect();
|
||||
let sig = config(&secret.primary_key, SignatureType::KeyRevocation, at, extra)
|
||||
.sign_key(&secret.primary_key, &Password::empty(), &key.primary_key)
|
||||
.unwrap();
|
||||
key.details.revocation_signatures.push(sig);
|
||||
key
|
||||
}
|
||||
|
||||
/// `secret`'s public key with its first subkey revoked at `at`.
|
||||
fn subkey_revoked(secret: &SignedSecretKey, at: u32) -> SignedPublicKey {
|
||||
let mut key = SignedPublicKey::from(secret.clone());
|
||||
let sig = config(&secret.primary_key, SignatureType::SubkeyRevocation, at, vec![])
|
||||
.sign_subkey_binding(&secret.primary_key, &key.primary_key, &Password::empty(), &key.public_subkeys[0].key)
|
||||
.unwrap();
|
||||
key.public_subkeys[0].signatures.push(sig);
|
||||
key
|
||||
}
|
||||
|
||||
/// `secret`'s public key with a new self-signature on its User ID number `user`, made at `at`.
|
||||
fn recertified(secret: &SignedSecretKey, key: &mut SignedPublicKey, user: usize, at: u32, extra: Vec<SubpacketData>) {
|
||||
let id = key.details.users[user].id.clone();
|
||||
let sig = config(&secret.primary_key, SignatureType::CertPositive, at, extra)
|
||||
.sign_certification(&secret.primary_key, &key.primary_key, &Password::empty(), Tag::UserId, &id)
|
||||
.unwrap();
|
||||
key.details.users[user].signatures.push(sig);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_without_the_revocation_never_undoes_it() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let mut keys = ring_of(&key_revoked(&alice, made(&alice), None));
|
||||
assert!(!keys.insert(public(&alice)), "nothing new in the copy");
|
||||
assert!(keys.keys()[0].revoked());
|
||||
assert!(revoked(&outcome(&signed_message(&alice, pgp::crypto::hash::HashAlgorithm::Sha256), ALICE, &keys)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_brings_a_revocation_always_counts() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let mut keys = ring_of(&SignedPublicKey::from(alice.clone()));
|
||||
let copy = PublicKey::parse(&key_revoked(&alice, made(&alice), None).to_bytes().unwrap()).unwrap().remove(0);
|
||||
assert!(keys.insert(copy));
|
||||
assert!(keys.keys()[0].revoked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_with_a_subkey_revocation_stripped_never_undoes_it() {
|
||||
let alice = secret(ALICE, Make { subkey: true, ..Make::default() });
|
||||
let mut keys = ring_of(&subkey_revoked(&alice, made(&alice)));
|
||||
let message = signed_at(&alice, made(&alice) + 100);
|
||||
assert!(revoked(&outcome(&message, ALICE, &keys)));
|
||||
keys.insert(public(&alice));
|
||||
assert!(revoked(&outcome(&message, ALICE, &keys)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primary_keys_expiry_and_revocation_hold_for_its_subkeys() {
|
||||
let alice = secret(ALICE, Make { subkey: true, ..Make::default() });
|
||||
let t = made(&alice);
|
||||
// The newest self-signature says the key expires 50 seconds after it was made.
|
||||
let mut key = SignedPublicKey::from(alice.clone());
|
||||
recertified(&alice, &mut key, 0, t + 10, vec![SubpacketData::KeyExpirationTime(Duration::from_secs(50))]);
|
||||
let keys = ring_of(&key);
|
||||
assert!(matches!(outcome(&signed_at(&alice, t + 20), ALICE, &keys), Outcome::Verified(_)));
|
||||
assert!(matches!(outcome(&signed_at(&alice, t + 100), ALICE, &keys), Outcome::CantCheck { why: CantCheck::KeyExpired, .. }));
|
||||
// A revoked primary key undoes its subkey's signatures.
|
||||
assert!(revoked(&outcome(&signed_at(&alice, t + 20), ALICE, &ring_of(&key_revoked(&alice, t + 50, None)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_soft_revocation_undoes_only_what_was_signed_after_it() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let t = made(&alice);
|
||||
for reason in [RevocationCode::KeySuperseded, RevocationCode::KeyRetired] {
|
||||
let keys = ring_of(&key_revoked(&alice, t + 50, Some(reason)));
|
||||
assert!(matches!(outcome(&signed_at(&alice, t + 20), ALICE, &keys), Outcome::Verified(_)));
|
||||
assert!(revoked(&outcome(&signed_at(&alice, t + 100), ALICE, &keys)));
|
||||
}
|
||||
// Compromised, or no reason: everything it ever signed.
|
||||
for reason in [Some(RevocationCode::KeyCompromised), None] {
|
||||
let keys = ring_of(&key_revoked(&alice, t + 50, reason));
|
||||
assert!(revoked(&outcome(&signed_at(&alice, t + 20), ALICE, &keys)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_user_id_self_signature_takes_its_address_away() {
|
||||
let alice = secret(ALICE, Make { alias: true, ..Make::default() });
|
||||
let t = made(&alice);
|
||||
let mut key = SignedPublicKey::from(alice.clone());
|
||||
let alias = key.details.users.iter().position(|u| u.id.id().starts_with(b"Alias")).unwrap();
|
||||
// The alias's newest self-signature expires 50 seconds after it was made; then a newer one
|
||||
// on the primary User ID that doesn't, so that the key itself stays valid.
|
||||
recertified(&alice, &mut key, alias, t + 10, vec![SubpacketData::SignatureExpirationTime(Duration::from_secs(50))]);
|
||||
recertified(&alice, &mut key, 1 - alias, t + 20, vec![]);
|
||||
let keys = ring_of(&key);
|
||||
let from_alias = |at| outcome(&signed_at(&alice, at), "alias@example.org", &keys);
|
||||
assert!(matches!(from_alias(t + 30), Outcome::Verified(_)));
|
||||
let Outcome::Valid { why, .. } = from_alias(t + 100) else { panic!() };
|
||||
assert_eq!(why, NotVerified { address_differs: true, ..NotVerified::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_subkey_binding_stops_the_subkey() {
|
||||
let alice = secret(ALICE, Make { subkey: true, ..Make::default() });
|
||||
let t = made(&alice);
|
||||
let mut key = SignedPublicKey::from(alice.clone());
|
||||
let back = key.public_subkeys[0].signatures[0].embedded_signature().unwrap().clone();
|
||||
let mut flags = pgp::packet::KeyFlags::default();
|
||||
flags.set_sign(true);
|
||||
let extra = vec![
|
||||
SubpacketData::KeyFlags(flags),
|
||||
SubpacketData::SignatureExpirationTime(Duration::from_secs(50)),
|
||||
SubpacketData::EmbeddedSignature(Box::new(back)),
|
||||
];
|
||||
let binding = config(&alice.primary_key, SignatureType::SubkeyBinding, t + 10, extra)
|
||||
.sign_subkey_binding(&alice.primary_key, &key.primary_key, &Password::empty(), &key.public_subkeys[0].key)
|
||||
.unwrap();
|
||||
key.public_subkeys[0].signatures.push(binding);
|
||||
let keys = ring_of(&key);
|
||||
assert!(matches!(outcome(&signed_at(&alice, t + 30), ALICE, &keys), Outcome::Verified(_)));
|
||||
assert!(matches!(outcome(&signed_at(&alice, t + 100), ALICE, &keys), Outcome::CantCheck { why: CantCheck::KeyExpired, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_under_2048_bits_is_too_weak_to_trust() {
|
||||
use pgp::composed::SignedKeyDetails;
|
||||
use pgp::crypto::public_key::PublicKeyAlgorithm;
|
||||
use pgp::packet::{PubKeyInner, PublicKey as Packet, SecretKey, UserId};
|
||||
use pgp::types::{KeyVersion, PlainSecretParams, PublicParams, RsaPublicParams, SecretParams, SignedUser, Timestamp};
|
||||
use rand::SeedableRng;
|
||||
// rPGP won't make a key this small, so it is put together from one the rsa crate makes.
|
||||
let private = rsa::RsaPrivateKey::new(&mut rand::rngs::StdRng::seed_from_u64(3), 1024).unwrap();
|
||||
let t = 1_704_067_200;
|
||||
let public_params = PublicParams::RSA(RsaPublicParams { key: private.to_public_key() });
|
||||
let inner = PubKeyInner::new(KeyVersion::V4, PublicKeyAlgorithm::RSA, Timestamp::from_secs(t), None, public_params).unwrap();
|
||||
let packet = Packet::from_inner(inner).unwrap();
|
||||
let secret = SecretKey::new(packet.clone(), SecretParams::Plain(PlainSecretParams::RSA(private.into()))).unwrap();
|
||||
let id = UserId::from_str(Default::default(), ALICE).unwrap();
|
||||
let mut flags = pgp::packet::KeyFlags::default();
|
||||
flags.set_sign(true);
|
||||
flags.set_certify(true);
|
||||
let cert = config(&secret, SignatureType::CertPositive, t, vec![SubpacketData::KeyFlags(flags)])
|
||||
.sign_certification(&secret, &packet, &Password::empty(), Tag::UserId, &id)
|
||||
.unwrap();
|
||||
let key = SignedPublicKey::new(packet, SignedKeyDetails::new(vec![], vec![], vec![SignedUser::new(id, vec![cert])], vec![]), vec![]);
|
||||
let keys = ring_of(&key);
|
||||
let sig = config(&secret, SignatureType::Binary, t + 1, vec![]).sign(&secret, &Password::empty(), SIGNED_PART.as_bytes()).unwrap();
|
||||
let armour = pgp::composed::DetachedSignature::new(sig).to_armored_string(Default::default()).unwrap();
|
||||
let out = outcome(&pgp_mime("alice@example.org", SIGNED_PART, &armour), ALICE, &keys);
|
||||
assert!(matches!(out, Outcome::CantCheck { why: CantCheck::WeakAlgorithm("RSA under 2048 bits"), .. }), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_keys_stops_when_its_budget_or_room_runs_out() {
|
||||
let block = armoured_public(&secret(ALICE, Make::default()));
|
||||
let many = block.repeat(20);
|
||||
// At most 16 keys from one call, however many blocks.
|
||||
assert_eq!(PublicKey::parse(many.as_bytes()).unwrap().len(), 16);
|
||||
// A budget of checks shared by everything one call reads: the key that would overspend it is
|
||||
// left out, whole, and so is every key after it.
|
||||
let mut budget = Budget::new(2);
|
||||
let read = PublicKey::parse_with(block.repeat(4).as_bytes(), &mut budget).unwrap();
|
||||
assert_eq!(read.len(), 2);
|
||||
assert!(budget.starved);
|
||||
}
|
||||
|
||||
/// A signature of `typ` that names Alice's key as its issuer but is made by Mallory's: junk
|
||||
/// anybody can add to a copy of Alice's key.
|
||||
fn forged(
|
||||
alice: &SignedSecretKey,
|
||||
mallory: &SignedSecretKey,
|
||||
typ: SignatureType,
|
||||
at: u32,
|
||||
extra: Vec<SubpacketData>,
|
||||
) -> pgp::packet::SignatureConfig {
|
||||
use pgp::types::KeyDetails;
|
||||
let mut config = config(&mallory.primary_key, typ, at, extra);
|
||||
for p in &mut config.hashed_subpackets {
|
||||
if matches!(p.data, SubpacketData::IssuerFingerprint(_)) {
|
||||
*p = pgp::packet::Subpacket::regular(SubpacketData::IssuerFingerprint(alice.primary_key.fingerprint())).unwrap();
|
||||
}
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// Inserts `bytes` as a copy of a key, when they read as one.
|
||||
fn offer(keys: &mut KeyRing, bytes: &[u8]) {
|
||||
for key in PublicKey::parse(bytes).unwrap_or_default() {
|
||||
keys.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_with_junk_subkey_bindings_from_the_future_never_pushes_out_the_real_one() {
|
||||
let alice = secret(ALICE, Make { subkey: true, ..Make::default() });
|
||||
let mallory = secret("Mallory <mallory@example.org>", Make { seed: 6, ..Make::default() });
|
||||
let mut copy = SignedPublicKey::from(alice.clone());
|
||||
let mut flags = pgp::packet::KeyFlags::default();
|
||||
flags.set_sign(true);
|
||||
for i in 0..64 {
|
||||
// Dated 2096.
|
||||
let junk = forged(&alice, &mallory, SignatureType::SubkeyBinding, 4_000_000_000 + i, vec![SubpacketData::KeyFlags(flags.clone())])
|
||||
.sign_subkey_binding(&mallory.primary_key, ©.primary_key, &Password::empty(), ©.public_subkeys[0].key)
|
||||
.unwrap();
|
||||
copy.public_subkeys[0].signatures.push(junk);
|
||||
}
|
||||
let junk = copy.to_bytes().unwrap();
|
||||
let message = signed_at(&alice, made(&alice) + 10);
|
||||
// Either way round, the real key stays whole, and nothing of the junk is kept.
|
||||
for first_real in [true, false] {
|
||||
let mut keys = KeyRing::new();
|
||||
if first_real {
|
||||
keys.insert(public(&alice));
|
||||
offer(&mut keys, &junk);
|
||||
} else {
|
||||
offer(&mut keys, &junk);
|
||||
keys.insert(public(&alice));
|
||||
}
|
||||
assert!(matches!(outcome(&message, ALICE, &keys), Outcome::Verified(_)), "real first: {first_real}");
|
||||
assert_eq!(keys.keys()[0].to_bytes(), public(&alice).to_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_costs_more_checks_than_a_key_may_is_refused_not_cut_short() {
|
||||
let alice = secret(ALICE, Make { subkey: true, ..Make::default() });
|
||||
let mallory = secret("Mallory <mallory@example.org>", Make { seed: 6, ..Make::default() });
|
||||
let mut copy = SignedPublicKey::from(alice.clone());
|
||||
// Junk User IDs with junk certifications before the subkey: checking them all would leave
|
||||
// no checks for the subkey's binding.
|
||||
for n in 0..5 {
|
||||
let id = pgp::packet::UserId::from_str(Default::default(), format!("Alice {n} <alice@example.org>")).unwrap();
|
||||
let sigs = (0..110)
|
||||
.map(|i| {
|
||||
forged(&alice, &mallory, SignatureType::CertPositive, made(&alice) + i, vec![])
|
||||
.sign_certification(&mallory.primary_key, ©.primary_key, &Password::empty(), Tag::UserId, &id)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
copy.details.users.push(pgp::types::SignedUser::new(id, sigs));
|
||||
}
|
||||
let junk = copy.to_bytes().unwrap();
|
||||
assert!(PublicKey::parse(&junk).is_err(), "a key not checked whole is no key");
|
||||
let mut keys = KeyRing::new();
|
||||
keys.insert(public(&alice));
|
||||
offer(&mut keys, &junk);
|
||||
assert!(matches!(outcome(&signed_at(&alice, made(&alice) + 10), ALICE, &keys), Outcome::Verified(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_whose_revocations_or_the_calls_budget_run_out_is_refused() {
|
||||
let alice = secret(ALICE, Make { subkey: true, ..Make::default() });
|
||||
let mallory = secret("Mallory <mallory@example.org>", Make { seed: 6, ..Make::default() });
|
||||
// More revocations than are checked: one left unchecked could be the real one.
|
||||
let mut copy = SignedPublicKey::from(alice.clone());
|
||||
for i in 0..257 {
|
||||
let junk = forged(&alice, &mallory, SignatureType::KeyRevocation, made(&alice) + i, vec![])
|
||||
.sign_key(&mallory.primary_key, &Password::empty(), ©.primary_key)
|
||||
.unwrap();
|
||||
copy.details.revocation_signatures.push(junk);
|
||||
}
|
||||
assert!(PublicKey::parse(©.to_bytes().unwrap()).is_err());
|
||||
// The call's budget runs out after the User ID, before the subkey.
|
||||
let bytes = SignedPublicKey::from(alice).to_bytes().unwrap();
|
||||
assert!(PublicKey::parse_with(&bytes, &mut Budget::new(1)).is_err());
|
||||
assert!(PublicKey::parse_with(&bytes, &mut Budget::new(3)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_id_certified_again_after_its_revocation_is_the_keys_again() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let t = made(&alice);
|
||||
let mut key = SignedPublicKey::from(alice.clone());
|
||||
let id = key.details.users[0].id.clone();
|
||||
let revocation = config(&alice.primary_key, SignatureType::CertRevocation, t + 10, vec![])
|
||||
.sign_certification(&alice.primary_key, &key.primary_key, &Password::empty(), Tag::UserId, &id)
|
||||
.unwrap();
|
||||
key.details.users[0].signatures.push(revocation);
|
||||
let revoked = ring_of(&key);
|
||||
assert!(revoked.keys()[0].addresses().is_empty(), "revoked");
|
||||
// Certified again in the same second as the revocation: which came last isn't known, so it
|
||||
// stays revoked.
|
||||
let mut tie = key.clone();
|
||||
recertified(&alice, &mut tie, 0, t + 10, vec![]);
|
||||
assert!(ring_of(&tie).keys()[0].addresses().is_empty(), "same second");
|
||||
recertified(&alice, &mut key, 0, t + 20, vec![]);
|
||||
assert_eq!(ring_of(&key).keys()[0].addresses(), ["alice@example.org".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_id_longer_than_any_real_one_is_left_out_unhashed() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let mut key = SignedPublicKey::from(alice.clone());
|
||||
let long = format!("{} <long@example.org>", "x".repeat(5_000));
|
||||
let id = pgp::packet::UserId::from_str(Default::default(), &long).unwrap();
|
||||
let cert = config(&alice.primary_key, SignatureType::CertPositive, made(&alice), vec![])
|
||||
.sign_certification(&alice.primary_key, &key.primary_key, &Password::empty(), Tag::UserId, &id)
|
||||
.unwrap();
|
||||
key.details.users.push(pgp::types::SignedUser::new(id, vec![cert]));
|
||||
let keys = ring_of(&key);
|
||||
assert_eq!(keys.keys()[0].addresses(), ["alice@example.org".to_string()]);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! OpenPGP for mail: checking a message's Digital signature and reading the Public keys it
|
||||
//! carries. No I/O, no clock and no keys of its own: the caller hands in the message's
|
||||
//! original bytes (what `Message::original` keeps), the Public keys it keeps
|
||||
//! ([`KeyRing`]), the time, and whether the owner has Verified a key; so it builds for the
|
||||
//! browser too, which checks but never signs.
|
||||
//!
|
||||
//! - [`verify`] checks a PGP/MIME message (`multipart/signed;
|
||||
//! protocol="application/pgp-signature"`, RFC 3156 §5) over the signed part's canonical
|
||||
//! bytes, or a cleartext-signed ("inline") text (RFC 9580 §7), and says which of the four
|
||||
//! results it is ([`Outcome`]). OpenPGP has no certificate authorities and this crate no web of
|
||||
//! trust: a key is Verified only when the owner marked it after comparing its fingerprint
|
||||
//! or their GnuPG keyring trusts it fully, both of which the caller knows and this doesn't.
|
||||
//! - [`keys_in_message`] reads the keys a message carries: attached `application/pgp-keys`
|
||||
//! parts and the `Autocrypt:` header (Autocrypt Level 1), for the caller to keep by
|
||||
//! address. Nothing is fetched or sent.
|
||||
//!
|
||||
//! The cryptography is rPGP's (`pgp`, MIT OR Apache-2.0); the MIME around it, the armour and the
|
||||
//! Cleartext Signature Framework are read here, from RFC 2045/2046, RFC 3156 and RFC 9580, so
|
||||
//! that every size is capped by this crate. Everything from outside is hostile: nothing here
|
||||
//! panics on any bytes (a panic inside rPGP is caught and is an unreadable signature), work is
|
||||
//! linear in the input, and sizes, counts and nesting are capped.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod armour;
|
||||
mod autocrypt;
|
||||
mod check;
|
||||
mod keys;
|
||||
mod mime;
|
||||
pub mod secret;
|
||||
|
||||
#[cfg(test)]
|
||||
mod armour_tests;
|
||||
#[cfg(test)]
|
||||
mod autocrypt_tests;
|
||||
#[cfg(test)]
|
||||
mod coverage_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "fixtures_tests.rs"]
|
||||
mod fixtures;
|
||||
#[cfg(test)]
|
||||
mod keys_tests;
|
||||
#[cfg(test)]
|
||||
mod mime_tests;
|
||||
#[cfg(test)]
|
||||
mod prop_tests;
|
||||
#[cfg(test)]
|
||||
mod secret_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod vector_tests;
|
||||
|
||||
pub use autocrypt::{Autocrypt, parse_autocrypt};
|
||||
pub use check::{CantCheck, Checked, Coverage, Issuer, NotVerified, Outcome, Signer};
|
||||
pub use keys::{Fingerprint, KeyId, KeyRing, PublicKey};
|
||||
|
||||
/// The largest message looked at, as large as the largest any server is fetched for.
|
||||
pub const MAX_MESSAGE: usize = 150 << 20;
|
||||
/// How deep into wrapping `multipart/mixed` parts the signed part is looked for, and how deep
|
||||
/// into multiparts an attached key.
|
||||
const MAX_DEPTH: usize = 8;
|
||||
/// How many parts of a message are looked at for attached keys.
|
||||
const MAX_PARTS: usize = 256;
|
||||
|
||||
/// Why key material couldn't be read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Not OpenPGP key material, or none of it checks.
|
||||
#[error("no Public key that can be read")]
|
||||
Unreadable,
|
||||
/// Larger than any real key or header.
|
||||
#[error("larger than a Public key can be")]
|
||||
TooLarge,
|
||||
/// More than one key where only one is taken (a secret key to import).
|
||||
#[error("more than one key")]
|
||||
SeveralKeys,
|
||||
}
|
||||
|
||||
/// The address of a From field's value, lower case: as [`verify`] takes it. Empty when the
|
||||
/// field names no one address (none, or several).
|
||||
pub fn from_address(value: &str) -> String {
|
||||
mime::address(value).trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Runs `f`, giving `None` instead of letting a panic out of it: rPGP is a large library
|
||||
/// reading hostile bytes, and a panic in it must not take the program down. Where panics abort (the
|
||||
/// browser build) this is only a function call.
|
||||
pub(crate) fn guard<T>(f: impl FnOnce() -> T) -> Option<T> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok()
|
||||
}
|
||||
|
||||
/// Checks the OpenPGP Digital signature of the message `original` (its bytes as they came),
|
||||
/// sent from `from` (the From field's value or its address), against `keys`, at `now` (seconds
|
||||
/// since 1970). `verified(fingerprint, address)` says whether the owner has Verified that key
|
||||
/// for that address: marked it after comparing its fingerprint, or their GnuPG keyring
|
||||
/// trusts it fully with that User ID.
|
||||
///
|
||||
/// `None` when the message has no OpenPGP Digital signature (an S/MIME one isn't this crate's,
|
||||
/// and one inside `multipart/alternative` is none: the other alternative would be shown
|
||||
/// unsigned). Never fails: what can't be read is [`CantCheck::Unreadable`]. A message is
|
||||
/// [`Outcome::Verified`] only when the signature covers all of it ([`Coverage::whole`]); one
|
||||
/// that covers part of it is at best [`Outcome::Valid`] with [`NotVerified::partly_signed`].
|
||||
pub fn verify(original: &[u8], from: &str, keys: &KeyRing, now: u64, verified: impl Fn(&Fingerprint, &str) -> bool) -> Option<Checked> {
|
||||
if original.len() > MAX_MESSAGE {
|
||||
return looks_signed(original).then(|| Checked { outcome: Outcome::cant(CantCheck::TooLarge), coverage: Coverage::default() });
|
||||
}
|
||||
let cx = check::Context {
|
||||
from: mime::address(from),
|
||||
keys,
|
||||
now,
|
||||
verified: &verified,
|
||||
verifications: std::cell::Cell::new(0),
|
||||
data_passes: std::cell::Cell::new(0),
|
||||
};
|
||||
let (headers, body) = mime::split(original);
|
||||
let mut checked = find(&headers, body, &mut Vec::new(), &cx)?;
|
||||
if !checked.coverage.whole {
|
||||
checked.outcome = match checked.outcome {
|
||||
Outcome::Verified(signer) => Outcome::Valid { signer, why: NotVerified { partly_signed: true, ..NotVerified::default() } },
|
||||
Outcome::Valid { signer, why } => Outcome::Valid { signer, why: NotVerified { partly_signed: true, ..why } },
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
Some(checked)
|
||||
}
|
||||
|
||||
/// Whether a message too large to check is a signed one, told cheaply: a PGP/MIME type at the
|
||||
/// top, or a cleartext-signed block anywhere in it.
|
||||
fn looks_signed(original: &[u8]) -> bool {
|
||||
let (headers, _) = mime::split(original.get(..1 << 20).unwrap_or(original));
|
||||
let content_type = headers.content_type();
|
||||
let pgp_mime = content_type.value == "multipart/signed"
|
||||
&& content_type.get("protocol").is_some_and(|p| p.trim().eq_ignore_ascii_case("application/pgp-signature"));
|
||||
pgp_mime || original.windows(34).any(|w| w == b"-----BEGIN PGP SIGNED MESSAGE-----")
|
||||
}
|
||||
|
||||
/// The signed part of a message at `path`, checked.
|
||||
fn find(headers: &mime::Headers, body: &[u8], path: &mut Vec<usize>, cx: &check::Context<'_>) -> Option<Checked> {
|
||||
let content_type = headers.content_type();
|
||||
match content_type.value.as_str() {
|
||||
"multipart/signed" => {
|
||||
let protocol = content_type.get("protocol").unwrap_or_default().trim().to_ascii_lowercase();
|
||||
if protocol != "application/pgp-signature" {
|
||||
return None;
|
||||
}
|
||||
// Only a PGP/MIME message at the top is all of the message.
|
||||
let coverage = Coverage { whole: path.is_empty(), path: path.clone(), span: None };
|
||||
let Some(boundary) = content_type.get("boundary").filter(|b| !b.is_empty()) else {
|
||||
return Some(Checked { outcome: Outcome::cant(CantCheck::Unreadable), coverage });
|
||||
};
|
||||
let parts = mime::parts(body, boundary, 3);
|
||||
// RFC 1847 §2.1: exactly two parts, what was signed and the signature.
|
||||
let [signed, signature] = parts.as_slice() else { return Some(Checked { outcome: Outcome::cant(CantCheck::Unreadable), coverage }) };
|
||||
let (sig_headers, sig_body) = mime::split(signature);
|
||||
let sig_bytes = mime::decode(&sig_headers.encoding(), sig_body);
|
||||
let packets = signature_packets(&sig_bytes);
|
||||
// RFC 3156 §5: the signed part with its header, in canonical form (CR LF).
|
||||
Some(Checked { outcome: check::check(&packets, &mime::crlf(signed), cx), coverage })
|
||||
}
|
||||
"text/plain" => {
|
||||
let text = mime::decode(&headers.encoding(), body);
|
||||
let clear = armour::cleartext(&text, check::MAX_SIGNATURE_BYTES)?;
|
||||
let coverage = Coverage { whole: path.is_empty() && clear.alone, path: path.clone(), span: Some(clear.span) };
|
||||
// Words shown in the block that aren't signed: nothing in it can be vouched for.
|
||||
let outcome = if clear.well_formed { check::check(&clear.signature, &clear.signed, cx) } else { Outcome::cant(CantCheck::Unreadable) };
|
||||
Some(Checked { outcome, coverage })
|
||||
}
|
||||
// A mailing list's wrapper: its first part, which is then only part of the message.
|
||||
// Never into `multipart/alternative`, whose other alternatives would be shown unsigned.
|
||||
"multipart/mixed" if path.len() < MAX_DEPTH => {
|
||||
let boundary = content_type.get("boundary").filter(|b| !b.is_empty())?;
|
||||
let first = *mime::parts(body, boundary, 1).first()?;
|
||||
let (part_headers, part_body) = mime::split(first);
|
||||
path.push(0);
|
||||
find(&part_headers, part_body, path, cx)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The signature packets of a PGP/MIME signature part: armoured, as RFC 3156 §5 says, or
|
||||
/// binary from a writer that didn't armour them. Empty when it holds neither, which checks as
|
||||
/// an unreadable signature: the message is still a signed one.
|
||||
fn signature_packets(bytes: &[u8]) -> Vec<u8> {
|
||||
let blocks = armour::blocks(bytes, armour::Kind::Signature, 1, check::MAX_SIGNATURE_BYTES);
|
||||
match blocks.into_iter().next() {
|
||||
Some(block) => block,
|
||||
None if bytes.first().is_some_and(|b| b & 0x80 != 0) && bytes.len() <= check::MAX_SIGNATURE_BYTES => bytes.to_vec(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Public keys a message carries, for the caller to keep by address.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Found {
|
||||
/// Keys in attached `application/pgp-keys` parts, their self-signatures checked.
|
||||
pub attached: Vec<PublicKey>,
|
||||
/// The sender's `Autocrypt:` header, when there is exactly one that reads and it is for
|
||||
/// the From address (Autocrypt Level 1 §2.3).
|
||||
pub autocrypt: Option<Autocrypt>,
|
||||
}
|
||||
|
||||
/// How many keys are taken from one message's attachments.
|
||||
const MAX_ATTACHED: usize = 16;
|
||||
/// How many `Autocrypt:` headers of one message are looked at.
|
||||
const MAX_AUTOCRYPT: usize = 8;
|
||||
|
||||
/// Reads the Public keys in the message `original`: attached key parts and the `Autocrypt:`
|
||||
/// header. Nothing is fetched or sent; what can't be read is left out. All of it together costs
|
||||
/// at most a fixed number of signature checks.
|
||||
pub fn keys_in_message(original: &[u8]) -> Found {
|
||||
let mut found = Found::default();
|
||||
if original.len() > MAX_MESSAGE {
|
||||
return found;
|
||||
}
|
||||
let mut total = keys::Budget::new(keys::MAX_CHECKS_PER_CALL);
|
||||
let (headers, body) = mime::split(original);
|
||||
// One From mailbox, or none counts.
|
||||
let from = headers.get("from").map(mime::address).unwrap_or_default();
|
||||
// Level 1 §2.3: not in a report (a bounce quotes the message it is about), and more than
|
||||
// one header for the address is as good as none. The attributes are read first, and the
|
||||
// key, which costs, only of the one header that counts.
|
||||
if headers.content_type().value != "multipart/report" && !from.is_empty() {
|
||||
let mut headers_for_from =
|
||||
headers.all("autocrypt").take(MAX_AUTOCRYPT).filter_map(|v| autocrypt::attributes(v).ok()).filter(|a| a.address == from);
|
||||
if let (Some(one), None) = (headers_for_from.next(), headers_for_from.next()) {
|
||||
found.autocrypt = one.read(&mut total).ok();
|
||||
}
|
||||
}
|
||||
let mut parts = MAX_PARTS;
|
||||
attached(&headers, body, 0, &mut parts, &mut total, &mut found.attached);
|
||||
found
|
||||
}
|
||||
|
||||
fn attached(headers: &mime::Headers, body: &[u8], depth: usize, parts: &mut usize, total: &mut keys::Budget, out: &mut Vec<PublicKey>) {
|
||||
if *parts == 0 || out.len() >= MAX_ATTACHED {
|
||||
return;
|
||||
}
|
||||
*parts = parts.saturating_sub(1);
|
||||
let content_type = headers.content_type();
|
||||
let media = content_type.value.as_str();
|
||||
if media == "application/pgp-keys" {
|
||||
let bytes = mime::decode(&headers.encoding(), body);
|
||||
if let Ok(keys) = PublicKey::parse_with(&bytes, total) {
|
||||
let room = MAX_ATTACHED.saturating_sub(out.len());
|
||||
out.extend(keys.into_iter().take(room));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if media.starts_with("multipart/") && depth < MAX_DEPTH {
|
||||
let Some(boundary) = content_type.get("boundary").filter(|b| !b.is_empty()) else { return };
|
||||
for part in mime::parts(body, boundary, *parts) {
|
||||
let (part_headers, part_body) = mime::split(part);
|
||||
attached(&part_headers, part_body, depth.saturating_add(1), parts, total, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
//! Just enough MIME to find what OpenPGP signed, written from RFC 2045, RFC 2046 and RFC 3156:
|
||||
//! a part's header fields and parameters, the parts of a multipart with their bytes exactly as
|
||||
//! they came, and the two transfer encodings that carry text. Everything works on borrowed bytes
|
||||
//! of the original message and is linear in them.
|
||||
|
||||
/// How many header fields of one part are read; the rest are ignored.
|
||||
const MAX_FIELDS: usize = 1_000;
|
||||
|
||||
/// A part's header fields, names in lower case, folded lines joined.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Headers {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Headers {
|
||||
/// The first field called `name` (lower case).
|
||||
pub(crate) fn get(&self, name: &str) -> Option<&str> {
|
||||
self.fields.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Every field called `name` (lower case), in order.
|
||||
pub(crate) fn all<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + 'a {
|
||||
self.fields.iter().filter(move |(n, _)| n == name).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// The part's media type and its parameters (`text/plain` when it says none, RFC 2045 §5.2).
|
||||
pub(crate) fn content_type(&self) -> Params {
|
||||
let params = self.get("content-type").map(Params::parse).unwrap_or_default();
|
||||
if params.value.is_empty() { Params { value: "text/plain".into(), ..params } } else { params }
|
||||
}
|
||||
|
||||
/// The part's transfer encoding, in lower case.
|
||||
pub(crate) fn encoding(&self) -> String {
|
||||
self.get("content-transfer-encoding").map(|e| e.trim().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a part into its header fields and its body: the body starts after the first empty
|
||||
/// line (or is empty when there is none).
|
||||
pub(crate) fn split(part: &[u8]) -> (Headers, &[u8]) {
|
||||
let (head, body) = match blank_line(part) {
|
||||
Some((head_end, body_start)) => (part.get(..head_end).unwrap_or_default(), part.get(body_start..).unwrap_or_default()),
|
||||
None => (part, &[][..]),
|
||||
};
|
||||
(parse_fields(head), body)
|
||||
}
|
||||
|
||||
/// Where the header of `part` ends and where its body starts.
|
||||
fn blank_line(part: &[u8]) -> Option<(usize, usize)> {
|
||||
// A part that starts with an empty line has no header fields at all.
|
||||
if part.starts_with(b"\r\n") {
|
||||
return Some((0, 2));
|
||||
}
|
||||
if part.starts_with(b"\n") {
|
||||
return Some((0, 1));
|
||||
}
|
||||
let mut i = 0usize;
|
||||
while let Some(off) = part.get(i..).and_then(|rest| rest.iter().position(|b| *b == b'\n')) {
|
||||
let nl = i.saturating_add(off);
|
||||
let next = nl.saturating_add(1);
|
||||
match part.get(next..) {
|
||||
Some(rest) if rest.starts_with(b"\r\n") => return Some((next, next.saturating_add(2))),
|
||||
Some(rest) if rest.starts_with(b"\n") => return Some((next, next.saturating_add(1))),
|
||||
Some(_) => i = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_fields(head: &[u8]) -> Headers {
|
||||
let mut fields: Vec<(String, String)> = Vec::new();
|
||||
for raw in head.split(|b| *b == b'\n') {
|
||||
let line = raw.strip_suffix(b"\r").unwrap_or(raw);
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if matches!(line.first(), Some(b' ' | b'\t')) {
|
||||
if let Some((_, value)) = fields.last_mut() {
|
||||
value.push(' ');
|
||||
value.push_str(String::from_utf8_lossy(line).trim());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if fields.len() >= MAX_FIELDS {
|
||||
continue;
|
||||
}
|
||||
let Some(colon) = line.iter().position(|b| *b == b':') else { continue };
|
||||
let name = String::from_utf8_lossy(line.get(..colon).unwrap_or_default()).trim().to_ascii_lowercase();
|
||||
let value = String::from_utf8_lossy(line.get(colon.saturating_add(1)..).unwrap_or_default()).trim().to_string();
|
||||
if !name.is_empty() {
|
||||
fields.push((name, value));
|
||||
}
|
||||
}
|
||||
Headers { fields }
|
||||
}
|
||||
|
||||
/// A header value with `;` parameters: `multipart/signed; protocol="application/pgp-signature"`.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(crate) struct Params {
|
||||
/// The value before the first `;`, in lower case.
|
||||
pub(crate) value: String,
|
||||
params: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
pub(crate) fn parse(text: &str) -> Params {
|
||||
let mut pieces = Pieces { text, at: 0 };
|
||||
let value = pieces.next().unwrap_or_default().trim().to_ascii_lowercase();
|
||||
let mut params = Vec::new();
|
||||
for piece in pieces {
|
||||
let Some((name, raw)) = piece.split_once('=') else { continue };
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
if !name.is_empty() && params.len() < MAX_FIELDS {
|
||||
params.push((name, unquote(raw.trim())));
|
||||
}
|
||||
}
|
||||
Params { value, params }
|
||||
}
|
||||
|
||||
/// The parameter called `name` (lower case).
|
||||
pub(crate) fn get(&self, name: &str) -> Option<&str> {
|
||||
self.params.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// The `;`-separated pieces of a header value, a `;` inside quotes not counting.
|
||||
struct Pieces<'a> {
|
||||
text: &'a str,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Pieces<'a> {
|
||||
type Item = &'a str;
|
||||
|
||||
fn next(&mut self) -> Option<&'a str> {
|
||||
let rest = self.text.get(self.at..)?;
|
||||
if rest.is_empty() && self.at > 0 {
|
||||
return None;
|
||||
}
|
||||
let (mut quoted, mut escaped) = (false, false);
|
||||
for (i, c) in rest.char_indices() {
|
||||
match c {
|
||||
_ if escaped => escaped = false,
|
||||
'\\' if quoted => escaped = true,
|
||||
'"' => quoted = !quoted,
|
||||
';' if !quoted => {
|
||||
self.at = self.at.saturating_add(i).saturating_add(1);
|
||||
return rest.get(..i);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.at = self.text.len().saturating_add(1);
|
||||
Some(rest)
|
||||
}
|
||||
}
|
||||
|
||||
fn unquote(raw: &str) -> String {
|
||||
let Some(inner) = raw.strip_prefix('"') else { return raw.to_string() };
|
||||
let mut out = String::new();
|
||||
let mut escaped = false;
|
||||
for c in inner.chars() {
|
||||
match c {
|
||||
_ if escaped => {
|
||||
out.push(c);
|
||||
escaped = false;
|
||||
}
|
||||
'\\' => escaped = true,
|
||||
'"' => break,
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parts of the multipart `body` with `boundary` (RFC 2046 §5.1.1), each exactly as it came:
|
||||
/// from after its delimiter line to before the line break that comes ahead of the next one,
|
||||
/// which belongs to the delimiter. At most `max` parts; a part after the last delimiter that
|
||||
/// isn't closed still counts, as mail programs read it.
|
||||
pub(crate) fn parts<'a>(body: &'a [u8], boundary: &str, max: usize) -> Vec<&'a [u8]> {
|
||||
let mut out = Vec::new();
|
||||
if boundary.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let mut dash = Vec::with_capacity(boundary.len().saturating_add(2));
|
||||
dash.extend_from_slice(b"--");
|
||||
dash.extend_from_slice(boundary.as_bytes());
|
||||
// The start of the current part, once its opening delimiter has been seen.
|
||||
let mut open: Option<usize> = None;
|
||||
let mut line_start = 0usize;
|
||||
while line_start <= body.len() {
|
||||
let rest = body.get(line_start..).unwrap_or_default();
|
||||
let line_len = rest.iter().position(|b| *b == b'\n');
|
||||
let next = line_len.map_or(body.len().saturating_add(1), |n| line_start.saturating_add(n).saturating_add(1));
|
||||
if let Some(after) = rest.strip_prefix(dash.as_slice()) {
|
||||
let line = after.get(..line_len.map_or(after.len(), |n| n.saturating_sub(dash.len()))).unwrap_or_default();
|
||||
let close = line.starts_with(b"--");
|
||||
let tail = if close { line.get(2..).unwrap_or_default() } else { line };
|
||||
if tail.iter().all(|b| matches!(b, b' ' | b'\t' | b'\r')) {
|
||||
if let Some(start) = open.take() {
|
||||
// The line break before the delimiter is the delimiter's.
|
||||
let mut end = line_start.saturating_sub(1);
|
||||
if end > start && body.get(end.saturating_sub(1)) == Some(&b'\r') {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
let end = end.max(start);
|
||||
out.push(body.get(start..end).unwrap_or_default());
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
if close {
|
||||
return out;
|
||||
}
|
||||
open = Some(next.min(body.len()));
|
||||
}
|
||||
}
|
||||
line_start = next;
|
||||
}
|
||||
if let Some(start) = open {
|
||||
out.push(body.get(start..).unwrap_or_default());
|
||||
}
|
||||
out.truncate(max);
|
||||
out
|
||||
}
|
||||
|
||||
/// `bytes` with every line break as CR LF (RFC 3156 §5: what was signed is the part's
|
||||
/// canonical form). A lone LF gets its CR; nothing else changes.
|
||||
pub(crate) fn crlf(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(bytes.len().saturating_add(bytes.len() / 32));
|
||||
let mut previous = 0u8;
|
||||
for &b in bytes {
|
||||
if b == b'\n' && previous != b'\r' {
|
||||
out.push(b'\r');
|
||||
}
|
||||
out.push(b);
|
||||
previous = b;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A leaf's body without its transfer encoding: base64 and quoted-printable are undone, every
|
||||
/// other encoding is the bytes themselves.
|
||||
pub(crate) fn decode(encoding: &str, body: &[u8]) -> Vec<u8> {
|
||||
match encoding {
|
||||
"base64" => base64(body),
|
||||
"quoted-printable" => quoted_printable(body),
|
||||
_ => body.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64 (RFC 2045 §6.8, RFC 4648), tolerant: characters outside the alphabet are skipped and
|
||||
/// decoding stops at the first `=`.
|
||||
pub(crate) fn base64(text: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(text.len() / 4 * 3);
|
||||
let (mut acc, mut bits) = (0u32, 0u32);
|
||||
for &c in text {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
b'=' => break,
|
||||
_ => continue,
|
||||
};
|
||||
acc = (acc << 6) | u32::from(v);
|
||||
bits = bits.saturating_add(6);
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xff) as u8);
|
||||
}
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Quoted-printable (RFC 2045 §6.7): `=XX` is a byte, `=` at a line's end joins it to the next.
|
||||
fn quoted_printable(text: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let mut i = 0usize;
|
||||
while let Some(&b) = text.get(i) {
|
||||
if b != b'=' {
|
||||
out.push(b);
|
||||
i = i.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
let hex = |o: usize| text.get(i.saturating_add(o)).and_then(|c| (*c as char).to_digit(16));
|
||||
if let (Some(h), Some(l)) = (hex(1), hex(2)) {
|
||||
out.push(((h << 4) | l) as u8);
|
||||
i = i.saturating_add(3);
|
||||
continue;
|
||||
}
|
||||
// A soft line break, with any white space the line ends in.
|
||||
let mut j = i.saturating_add(1);
|
||||
while matches!(text.get(j), Some(b' ' | b'\t')) {
|
||||
j = j.saturating_add(1);
|
||||
}
|
||||
match (text.get(j), text.get(j.saturating_add(1))) {
|
||||
(Some(b'\r'), Some(b'\n')) => i = j.saturating_add(2),
|
||||
(Some(b'\n'), _) => i = j.saturating_add(1),
|
||||
(None, _) => i = j,
|
||||
_ => {
|
||||
out.push(b'=');
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The address of the one mailbox in a From field (or an address given as it is): what is in
|
||||
/// its `<…>` outside quoted strings and comments, else the value without its comments, in
|
||||
/// lower case. Empty when there is no one address in it.
|
||||
pub(crate) fn address(value: &str) -> String {
|
||||
// RFC 5322 §3.4: a mailbox is `name <addr>` or a bare addr-spec. Quoted strings and comments
|
||||
// are read past, so that nothing in them is taken for the address; a field with more than
|
||||
// one mailbox (§3.6.2: a comma between them, or more than one `<…>`) has no one address.
|
||||
let (mut quoted, mut escaped, mut comment) = (false, false, 0usize);
|
||||
let (mut angle, mut angles, mut closed) = (false, 0usize, false);
|
||||
let (mut inside, mut outside) = (String::new(), String::new());
|
||||
for c in value.chars() {
|
||||
let text = comment == 0;
|
||||
match c {
|
||||
_ if escaped => escaped = false,
|
||||
'\\' if quoted || comment > 0 => {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
'"' if comment == 0 => quoted = !quoted,
|
||||
_ if quoted => {}
|
||||
'(' => comment = comment.saturating_add(1),
|
||||
')' => comment = comment.saturating_sub(1),
|
||||
_ if comment > 0 => {}
|
||||
'<' if angle => return String::new(),
|
||||
'<' => {
|
||||
angle = true;
|
||||
angles = angles.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
'>' if angle => {
|
||||
angle = false;
|
||||
closed = true;
|
||||
continue;
|
||||
}
|
||||
',' if !angle => return String::new(),
|
||||
_ => {}
|
||||
}
|
||||
// What is in a comment, and the parentheses around it, is neither.
|
||||
if text && comment == 0 && c != ')' {
|
||||
if angle { inside.push(c) } else { outside.push(c) }
|
||||
}
|
||||
}
|
||||
let spec = match angles {
|
||||
0 => outside,
|
||||
1 if closed && !angle => inside,
|
||||
_ => return String::new(),
|
||||
};
|
||||
let spec = spec.trim();
|
||||
// A control or format character (a zero-width space, a direction mark) would make an
|
||||
// address that looks like another one: none is taken.
|
||||
let clean = spec.matches('@').count() == 1
|
||||
&& !spec.chars().any(|c| c.is_whitespace() || c.is_control() || is_format(c) || matches!(c, '<' | '>' | '"' | '(' | ')'));
|
||||
if clean { spec.to_lowercase() } else { String::new() }
|
||||
}
|
||||
|
||||
/// Whether `c` is one of Unicode's format characters (general category Cf, Unicode 17.0): the
|
||||
/// soft hyphen, the Arabic number signs and end-of-ayah marks, the Syriac abbreviation mark, the
|
||||
/// Arabic pound and piastre marks above, the Mongolian vowel separator, zero-width marks and
|
||||
/// joiners, bidirectional marks, embeddings, overrides and isolates, the word joiner and
|
||||
/// invisible operators, the deprecated shaping controls, the byte-order mark, interlinear
|
||||
/// annotations, the Kaithi number signs, the Egyptian hieroglyph, shorthand and musical format
|
||||
/// controls, and tags.
|
||||
pub(crate) fn is_format(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{00ad}'
|
||||
| '\u{0600}'..='\u{0605}'
|
||||
| '\u{061c}'
|
||||
| '\u{06dd}'
|
||||
| '\u{070f}'
|
||||
| '\u{0890}'..='\u{0891}'
|
||||
| '\u{08e2}'
|
||||
| '\u{180e}'
|
||||
| '\u{200b}'..='\u{200f}'
|
||||
| '\u{202a}'..='\u{202e}'
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{206f}'
|
||||
| '\u{feff}'
|
||||
| '\u{fff9}'..='\u{fffb}'
|
||||
| '\u{110bd}'
|
||||
| '\u{110cd}'
|
||||
| '\u{13430}'..='\u{1343f}'
|
||||
| '\u{1bca0}'..='\u{1bca3}'
|
||||
| '\u{1d173}'..='\u{1d17a}'
|
||||
| '\u{e0001}'
|
||||
| '\u{e0020}'..='\u{e007f}'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use crate::mime::{Params, address, base64, crlf, decode, parts, split};
|
||||
|
||||
#[test]
|
||||
fn headers_unfold_and_parameters_unquote() {
|
||||
let (headers, body) =
|
||||
split(b"Content-Type: multipart/signed;\r\n\tprotocol=\"application/pgp-signature\";\r\n micalg=pgp-sha256; boundary=\"a;b\"\r\n\r\nbody");
|
||||
assert_eq!(body, b"body");
|
||||
let ct = headers.content_type();
|
||||
assert_eq!(ct.value, "multipart/signed");
|
||||
assert_eq!(ct.get("protocol"), Some("application/pgp-signature"));
|
||||
assert_eq!(ct.get("boundary"), Some("a;b"));
|
||||
assert_eq!(ct.get("micalg"), Some("pgp-sha256"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_without_fields_or_type_is_plain_text() {
|
||||
let (headers, body) = split(b"\r\nonly a body");
|
||||
assert_eq!(body, b"only a body");
|
||||
assert_eq!(headers.content_type().value, "text/plain");
|
||||
let (_, body) = split(b"Subject: no body at all");
|
||||
assert!(body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_are_cut_exactly_with_the_line_break_before_a_delimiter_belonging_to_it() {
|
||||
let body = b"preamble\r\n--b\r\nA: 1\r\n\r\nfirst\r\n\r\n--b \r\nsecond\r\n--b--\r\nepilogue";
|
||||
assert_eq!(parts(body, "b", 10), vec![&b"A: 1\r\n\r\nfirst\r\n"[..], &b"second"[..]]);
|
||||
// Bare line feeds, and a delimiter that only starts like the boundary isn't one.
|
||||
let body = b"--b\nx\n--bc\ny\n--b--\n";
|
||||
assert_eq!(parts(body, "b", 10), vec![&b"x\n--bc\ny"[..]]);
|
||||
// A part that is never closed still counts; an empty one too.
|
||||
assert_eq!(parts(b"--b\r\n--b\r\nrest", "b", 10), vec![&b""[..], &b"rest"[..]]);
|
||||
assert_eq!(parts(b"--b\r\na\r\n--b\r\nb\r\n--b--", "b", 1), vec![&b"a"[..]]);
|
||||
assert!(parts(b"no delimiters", "b", 10).is_empty());
|
||||
assert!(parts(b"--\r\n", "", 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_form_adds_carriage_returns_only_where_missing() {
|
||||
assert_eq!(crlf(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n");
|
||||
assert_eq!(crlf(b""), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_encodings_are_undone() {
|
||||
assert_eq!(base64(b"SGVs\r\nbG8=\r\n"), b"Hello");
|
||||
assert_eq!(decode("base64", b"!!QUJD"), b"ABC");
|
||||
assert_eq!(decode("quoted-printable", b"a=3Db=\r\nc= \r\nd=ZZ="), b"a=bcd=ZZ");
|
||||
assert_eq!(decode("7bit", b"same"), b"same");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_are_taken_from_angle_brackets_and_lower_cased() {
|
||||
assert_eq!(address("Alice <Alice@Example.org>"), "alice@example.org");
|
||||
assert_eq!(address(" bob@example.org "), "bob@example.org");
|
||||
assert_eq!(address("\"a <x>\" <c@example.org>"), "c@example.org");
|
||||
assert_eq!(address("no address"), "");
|
||||
assert_eq!(address("two words@example.org"), "");
|
||||
// An address as the display name, as many writers send it.
|
||||
assert_eq!(address("bob@example.org <Bob@example.org>"), "bob@example.org");
|
||||
assert_eq!(address("\"Bob, the builder\" <bob@example.org>"), "bob@example.org");
|
||||
// Two mailboxes: no one address.
|
||||
assert_eq!(address("alice@example.org, Mallory <mallory@example.org>"), "");
|
||||
assert_eq!(address("Alice <alice@example.org> <mallory@example.org>"), "");
|
||||
assert_eq!(address("alice@example.org mallory@example.org"), "");
|
||||
// What is in a comment or a quoted string is never the address, wherever it is.
|
||||
assert_eq!(address("Mallory <mallory@evil.example> (<alice@example.org>)"), "mallory@evil.example");
|
||||
assert_eq!(address("Mallory <mallory@evil.example> \"<alice@example.org>\""), "mallory@evil.example");
|
||||
assert_eq!(address("mallory@evil.example (alice@example.org)"), "mallory@evil.example");
|
||||
assert_eq!(address("Alice <alice@example.org"), "");
|
||||
// Characters that don't show (or show as nothing) make no address.
|
||||
for hidden in ["\u{200b}", "\0", "\u{202e}", "\u{7f}", "\u{feff}"] {
|
||||
assert_eq!(address(&format!("Mallory <alice@example.org{hidden}>")), "", "{hidden:?}");
|
||||
assert_eq!(address(&format!("ali{hidden}ce@example.org")), "", "{hidden:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_autocrypt_header_is_matched_to_the_address_readers_show() {
|
||||
use crate::fixtures::{Make, base64, secret};
|
||||
let alice = secret("Alice <alice@example.org>", Make::default());
|
||||
let keydata = base64(&pgp::ser::Serialize::to_bytes(&pgp::composed::SignedPublicKey::from(alice)).unwrap()).replace("\r\n", "");
|
||||
for from in ["Mallory <mallory@evil.example> (<alice@example.org>)", "Mallory <mallory@evil.example> \"<alice@example.org>\""] {
|
||||
let message = format!("From: {from}\r\nAutocrypt: addr=alice@example.org; keydata={keydata}\r\n\r\nHi\r\n");
|
||||
assert!(crate::keys_in_message(message.as_bytes()).autocrypt.is_none(), "{from}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_with_no_value_and_odd_quoting_dont_break() {
|
||||
let p = Params::parse("text/plain; ; =x; a=\"unterminated; b=\\\"");
|
||||
assert_eq!(p.value, "text/plain");
|
||||
assert_eq!(p.get("a"), Some("unterminated; b=\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_format_characters_are_unicode_cf() {
|
||||
// General category Cf in Unicode 17.0 has 170 characters.
|
||||
assert_eq!((0..=0x10ffffu32).filter_map(char::from_u32).filter(|c| crate::mime::is_format(*c)).count(), 170);
|
||||
assert!(address("ada\u{200b}@example.org").is_empty());
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Hostile input: arbitrary bytes into every reader never panic, and stay within bounds.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::armour::{Kind, blocks, cleartext};
|
||||
use crate::fixtures::{ALICE_FOR_PROPS, LATER};
|
||||
use crate::{KeyRing, PublicKey, keys_in_message, mime, parse_autocrypt, verify};
|
||||
|
||||
/// Bytes that look like the pieces of a signed message, shuffled and cut, so the readers go
|
||||
/// deeper than random bytes would take them.
|
||||
fn mail_like() -> impl Strategy<Value = Vec<u8>> {
|
||||
let pieces = prop::sample::select(vec![
|
||||
&b"Content-Type: multipart/signed; protocol=\"application/pgp-signature\"; boundary=b\r\n"[..],
|
||||
b"Content-Type: multipart/mixed; boundary=b\r\n",
|
||||
b"Content-Type: application/pgp-keys\r\n",
|
||||
b"Content-Transfer-Encoding: base64\r\n",
|
||||
b"Content-Transfer-Encoding: quoted-printable\r\n",
|
||||
b"Autocrypt: addr=a@example.org; keydata=xjMEZ\r\n",
|
||||
b"From: a@example.org\r\n",
|
||||
b"\r\n",
|
||||
b"\n",
|
||||
b"--b\r\n",
|
||||
b"--b--\r\n",
|
||||
b"-----BEGIN PGP SIGNED MESSAGE-----\r\n",
|
||||
b"-----BEGIN PGP SIGNATURE-----\r\n",
|
||||
b"-----END PGP SIGNATURE-----\r\n",
|
||||
b"-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n",
|
||||
b"-----END PGP PUBLIC KEY BLOCK-----\r\n",
|
||||
b"- - dashed\r\n",
|
||||
b"wnUEARYKAB0WIQ\r\n",
|
||||
b"xjMEZ0AAABYJKwYBBAHaRw8BAQdA\r\n",
|
||||
b"=AAAA\r\n",
|
||||
b"\xc2\x00\xff\x80\x01",
|
||||
]);
|
||||
(prop::collection::vec(pieces, 0..40), prop::collection::vec(any::<u8>(), 0..64)).prop_map(|(pieces, noise)| {
|
||||
let mut out: Vec<u8> = pieces.concat();
|
||||
out.extend_from_slice(&noise);
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn verify_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..2048)) {
|
||||
let keys: KeyRing = ALICE_FOR_PROPS.iter().cloned().collect();
|
||||
let _ = verify(&bytes, "a@example.org", &keys, LATER, |_, _| true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_never_panics_on_mail_like_bytes(bytes in mail_like()) {
|
||||
let keys: KeyRing = ALICE_FOR_PROPS.iter().cloned().collect();
|
||||
let _ = verify(&bytes, "a@example.org", &keys, LATER, |_, _| true);
|
||||
let _ = keys_in_message(&bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..2048)) {
|
||||
let _ = PublicKey::parse(&bytes);
|
||||
let keys: KeyRing = ALICE_FOR_PROPS.iter().cloned().collect();
|
||||
let _ = crate::check::check(&bytes, b"data", &crate::check::Context { from: "a@example.org".into(), keys: &keys, now: LATER, verified: &|_, _| true, verifications: Default::default(), data_passes: Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armour_stays_within_its_limits(bytes in mail_like(), limit in 0usize..64, max in 0usize..4) {
|
||||
let found = blocks(&bytes, Kind::Signature, max, limit);
|
||||
prop_assert!(found.len() <= max);
|
||||
prop_assert!(found.iter().all(|b| b.len() <= limit));
|
||||
if let Some(clear) = cleartext(&bytes, limit) {
|
||||
prop_assert!(clear.signature.len() <= limit);
|
||||
prop_assert!(clear.signed.len() <= bytes.len().saturating_mul(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocrypt_headers_never_panic(value in "\\PC{0,300}") {
|
||||
let _ = parse_autocrypt(&value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_pieces_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..1024), boundary in "[a-z=-]{0,4}") {
|
||||
let (headers, body) = mime::split(&bytes);
|
||||
let _ = headers.content_type();
|
||||
let found = mime::parts(body, &boundary, 8);
|
||||
prop_assert!(found.len() <= 8);
|
||||
let _ = mime::decode("quoted-printable", &bytes);
|
||||
let _ = mime::decode("base64", &bytes);
|
||||
prop_assert!(mime::crlf(&bytes).len() <= bytes.len() * 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! A transferable secret key (RFC 9580 §10.2) read for the window to import: what it
|
||||
//! is (its fingerprint and addresses, its public half for the key ring), whether every secret
|
||||
//! part of it is protected by a passphrase, whether any part is RSA, and whether it can sign.
|
||||
//! Nothing secret is used here (an rPGP advisory concerns using an RSA private key, which this
|
||||
//! never does), and the secret bytes are not kept. The one thing decrypted is a trial: each
|
||||
//! secret part of a key with no RSA part is tried with an empty passphrase (the passphrase's key
|
||||
//! derivation and a symmetric decryption, within caps), so that a key "protected" by an empty
|
||||
//! one is told as unprotected. The file is hostile: the caps of the Public keys' reader hold, a
|
||||
//! file holds exactly one secret key, and nothing here panics on any bytes.
|
||||
|
||||
use pgp::composed::{Deserializable, SignedSecretKey};
|
||||
use pgp::types::{KeyDetails, Password, S2kParams, SecretParams, StringToKey};
|
||||
|
||||
use crate::Error;
|
||||
use crate::armour::{self, Kind};
|
||||
use crate::keys::{Budget, Fingerprint, MAX_CHECKS_PER_CALL, MAX_KEY_BYTES, PublicKey, packet_tags, packets};
|
||||
|
||||
/// How many packets a secret key may be made of.
|
||||
const MAX_PACKETS: usize = 4_096;
|
||||
/// The packet tags of a secret key and a secret subkey (RFC 9580 §5.5.1.3, §5.5.1.4).
|
||||
const SECRET_KEY: u8 = 5;
|
||||
const SECRET_SUBKEY: u8 = 7;
|
||||
/// The most secret parts (the primary key and its subkeys) a key to import may have: each is
|
||||
/// tried with an empty passphrase, which costs a key derivation.
|
||||
pub const MAX_SECRET_PARTS: usize = 16;
|
||||
/// The most memory an Argon2 derivation of that trial may ask for, as RFC 9580's exponent of
|
||||
/// KiB (2^18 KiB: 256 MiB), and the most passes. A part asking for more isn't tried, and is
|
||||
/// taken as protected: nobody sets so costly a derivation for an empty passphrase.
|
||||
const MAX_ARGON2_MEMORY: u8 = 18;
|
||||
const MAX_ARGON2_PASSES: u8 = 8;
|
||||
|
||||
/// What a secret key is, read without using anything secret.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SecretKeyInfo {
|
||||
/// The primary key's fingerprint.
|
||||
pub fingerprint: Fingerprint,
|
||||
/// The addresses of the User IDs it certified itself, lower case.
|
||||
pub addresses: Vec<String>,
|
||||
/// Its public half, its self-signatures checked: for the key ring, and to keep for its
|
||||
/// addresses.
|
||||
pub public: PublicKey,
|
||||
/// Whether every secret part of it (the primary key's and each subkey's) is protected by a
|
||||
/// passphrase that isn't empty.
|
||||
pub protected: bool,
|
||||
/// Whether any secret part of it (the primary key or a subkey, whatever it is for) is RSA,
|
||||
/// whose private-key operations never run in this process.
|
||||
pub any_rsa: bool,
|
||||
/// Whether it can make Digital signatures: a key of it that may sign, isn't revoked, isn't
|
||||
/// of an algorithm no longer safe, and has its secret part here.
|
||||
pub can_sign: bool,
|
||||
/// The algorithm of the key it would sign with (the first that can): whether it may be
|
||||
/// used here. `None` when it can't sign.
|
||||
pub signing_kind: Option<SigningKind>,
|
||||
}
|
||||
|
||||
/// What kind of key signs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SigningKind {
|
||||
Rsa,
|
||||
/// EdDSA: Ed25519 (either way of writing it) or Ed448.
|
||||
EdDsa,
|
||||
Ecdsa,
|
||||
/// Any other (DSA, or one not known).
|
||||
Other,
|
||||
}
|
||||
|
||||
impl SigningKind {
|
||||
fn of(algorithm: pgp::crypto::public_key::PublicKeyAlgorithm) -> SigningKind {
|
||||
use pgp::crypto::public_key::PublicKeyAlgorithm as A;
|
||||
match algorithm {
|
||||
A::RSA | A::RSASign | A::RSAEncrypt => SigningKind::Rsa,
|
||||
A::EdDSALegacy | A::Ed25519 | A::Ed448 => SigningKind::EdDsa,
|
||||
A::ECDSA => SigningKind::Ecdsa,
|
||||
_ => SigningKind::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the transferable secret key in `bytes`: armoured (`-----BEGIN PGP PRIVATE KEY
|
||||
/// BLOCK-----`) or binary. [`Error::Unreadable`] when there is no secret key, or its
|
||||
/// self-signatures don't check; [`Error::TooLarge`] past the size of any key or past
|
||||
/// [`MAX_SECRET_PARTS`]; [`Error::SeveralKeys`] when the file holds more than one key (two
|
||||
/// armour blocks, two secret key packets, or anything after the key's packets): the whole file
|
||||
/// is what is kept, so the key checked must be all there is in it.
|
||||
pub fn read(bytes: &[u8]) -> Result<SecretKeyInfo, Error> {
|
||||
if bytes.windows(15).filter(|w| *w == b"-----BEGIN PGP ").nth(1).is_some() {
|
||||
return Err(Error::SeveralKeys);
|
||||
}
|
||||
let block = match armour::blocks(bytes, Kind::PrivateKey, 1, MAX_KEY_BYTES).into_iter().next() {
|
||||
Some(block) => block,
|
||||
None if bytes.len() > MAX_KEY_BYTES => return Err(Error::TooLarge),
|
||||
None => bytes.to_vec(),
|
||||
};
|
||||
let (tags, end) = packets(&block, MAX_PACKETS.saturating_add(1));
|
||||
if tags.len() > MAX_PACKETS || tags.first() != Some(&SECRET_KEY) {
|
||||
return Err(Error::Unreadable);
|
||||
}
|
||||
if tags.iter().filter(|t| **t == SECRET_KEY).count() > 1 || end != block.len() {
|
||||
return Err(Error::SeveralKeys);
|
||||
}
|
||||
if tags.iter().filter(|t| matches!(**t, SECRET_KEY | SECRET_SUBKEY)).count() > MAX_SECRET_PARTS {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
let parsed = crate::guard(|| SignedSecretKey::from_bytes_many(block.as_slice()).map(|mut it| (it.next(), it.next().is_some())));
|
||||
let key = match parsed {
|
||||
Some(Ok((Some(Ok(key)), false))) => key,
|
||||
Some(Ok((Some(Ok(_)), true))) => return Err(Error::SeveralKeys),
|
||||
_ => return Err(Error::Unreadable),
|
||||
};
|
||||
crate::guard(|| info(&key)).flatten().ok_or(Error::Unreadable)
|
||||
}
|
||||
|
||||
fn info(key: &SignedSecretKey) -> Option<SecretKeyInfo> {
|
||||
let public = PublicKey::checked(key.to_public_key(), &mut Budget::new(MAX_CHECKS_PER_CALL))?;
|
||||
let encrypted = key.primary_key.secret_params().is_encrypted() && key.secret_subkeys.iter().all(|s| s.key.secret_params().is_encrypted());
|
||||
let any_rsa = SigningKind::of(key.primary_key.algorithm()) == SigningKind::Rsa
|
||||
|| key.secret_subkeys.iter().any(|s| SigningKind::of(s.key.algorithm()) == SigningKind::Rsa);
|
||||
// (An RSA key is refused whatever its passphrase: its secret parts are never decrypted here.)
|
||||
let protected = encrypted && (any_rsa || !opens_without_passphrase(key));
|
||||
let mut secret: Vec<Fingerprint> = vec![Fingerprint::of(&key.primary_key.fingerprint())];
|
||||
secret.extend(key.secret_subkeys.iter().map(|s| Fingerprint::of(&s.key.fingerprint())));
|
||||
let signer = public.signing.iter().find(|s| s.may_sign && s.revoked.is_none() && s.weak.is_none() && secret.contains(&s.fingerprint));
|
||||
let can_sign = signer.is_some();
|
||||
let signing_kind = signer.map(|s| SigningKind::of(s.algorithm));
|
||||
Some(SecretKeyInfo {
|
||||
fingerprint: public.fingerprint().clone(),
|
||||
addresses: public.addresses().to_vec(),
|
||||
public,
|
||||
protected,
|
||||
any_rsa,
|
||||
can_sign,
|
||||
signing_kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a secret part of `key` opens with an empty passphrase: its derivation and symmetric
|
||||
/// decryption run (within [`MAX_ARGON2_MEMORY`] and [`MAX_ARGON2_PASSES`]; a part asking for more
|
||||
/// isn't tried) and its checksum or authentication tag checks. Only for a key with no RSA part.
|
||||
fn opens_without_passphrase(key: &SignedSecretKey) -> bool {
|
||||
let empty = Password::empty();
|
||||
let tried = |params: &SecretParams| matches!(params, SecretParams::Encrypted(e) if bounded(e.string_to_key_params()));
|
||||
let primary = tried(key.primary_key.secret_params()) && matches!(key.primary_key.unlock(&empty, |_, _| Ok(())), Ok(Ok(())));
|
||||
primary || key.secret_subkeys.iter().any(|s| tried(s.key.secret_params()) && matches!(s.key.unlock(&empty, |_, _| Ok(())), Ok(Ok(()))))
|
||||
}
|
||||
|
||||
/// Whether a passphrase's derivation is cheap enough to try.
|
||||
fn bounded(params: &S2kParams) -> bool {
|
||||
let s2k = match params {
|
||||
S2kParams::Unprotected => return false,
|
||||
S2kParams::LegacyCfb { .. } => return true,
|
||||
S2kParams::Aead { s2k, .. } | S2kParams::Cfb { s2k, .. } | S2kParams::MalleableCfb { s2k, .. } => s2k,
|
||||
};
|
||||
match s2k {
|
||||
StringToKey::Argon2 { t, m_enc, .. } => *m_enc <= MAX_ARGON2_MEMORY && *t <= MAX_ARGON2_PASSES,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` hold OpenPGP secret key material: an armoured `PRIVATE KEY BLOCK`, or binary
|
||||
/// packets among which is a secret key or subkey. So that a secret key is never taken for a
|
||||
/// Public key to import.
|
||||
pub fn holds_secret(bytes: &[u8]) -> bool {
|
||||
if bytes.windows(25).any(|w| w == b"BEGIN PGP PRIVATE KEY BLO") {
|
||||
return true;
|
||||
}
|
||||
let secret = |packets: &[u8]| packet_tags(packets, MAX_PACKETS).iter().any(|t| matches!(*t, SECRET_KEY | SECRET_SUBKEY));
|
||||
secret(bytes) || armour::blocks(bytes, Kind::PublicKey, crate::keys::MAX_KEYS_PER_BLOCK, MAX_KEY_BYTES).iter().any(|b| secret(b))
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Secret keys read for import (#30 R13), without anything decrypted: synthetic `example.org`
|
||||
//! keys only.
|
||||
|
||||
use pgp::composed::{ArmorOptions, SignedSecretKey};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::Password;
|
||||
use proptest::prelude::*;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::StdRng;
|
||||
|
||||
use crate::fixtures::{Make, armoured_public, public, secret};
|
||||
use crate::secret::{SigningKind, holds_secret, read};
|
||||
use crate::{Error, KeyRing};
|
||||
|
||||
fn protect(key: &mut SignedSecretKey, primary: bool, subkeys: bool) {
|
||||
let password: Password = "correct horse".into();
|
||||
if primary {
|
||||
key.primary_key.set_password(StdRng::seed_from_u64(3), &password).unwrap();
|
||||
}
|
||||
if subkeys {
|
||||
for sub in &mut key.secret_subkeys {
|
||||
sub.key.set_password(StdRng::seed_from_u64(4), &password).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_key_is_read_armoured_or_binary_without_being_decrypted() {
|
||||
let key = secret("Ada <ada@example.org>", Make::default());
|
||||
let armoured = key.to_armored_string(ArmorOptions::default()).unwrap();
|
||||
for bytes in [armoured.as_bytes().to_vec(), key.to_bytes().unwrap()] {
|
||||
let info = read(&bytes).unwrap();
|
||||
assert_eq!(info.addresses, vec!["ada@example.org".to_string()]);
|
||||
assert_eq!(&info.fingerprint, public(&key).fingerprint());
|
||||
assert!(!info.protected, "a key with no passphrase");
|
||||
assert!(info.can_sign);
|
||||
assert_eq!(info.signing_kind, Some(SigningKind::EdDsa));
|
||||
// Its public half goes in a ring and finds its signatures.
|
||||
let ring: KeyRing = std::iter::once(info.public).collect();
|
||||
assert_eq!(ring.for_address("ada@example.org").count(), 1);
|
||||
assert!(holds_secret(&bytes));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whether_every_secret_part_is_protected_is_told() {
|
||||
let mut key = secret("Ada <ada@example.org>", Make { subkey: true, ..Make::default() });
|
||||
protect(&mut key, true, false);
|
||||
let info = read(&key.to_bytes().unwrap()).unwrap();
|
||||
assert!(!info.protected, "its subkey isn't");
|
||||
assert!(info.can_sign, "its signing subkey is here");
|
||||
protect(&mut key, false, true);
|
||||
assert!(read(&key.to_bytes().unwrap()).unwrap().protected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_whose_signing_subkey_isnt_here_cant_sign() {
|
||||
let mut key = secret("Ada <ada@example.org>", Make { subkey: true, ..Make::default() });
|
||||
key.secret_subkeys.clear();
|
||||
let info = read(&key.to_bytes().unwrap()).unwrap();
|
||||
assert!(!info.can_sign, "the primary key may only certify");
|
||||
assert_eq!(info.signing_kind, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_rsa_key_says_so() {
|
||||
// (RSA keys are never used in this process: the window refuses them.)
|
||||
let key = secret("Ada <ada@example.org>", Make { rsa: Some(2048), ..Make::default() });
|
||||
let info = read(&key.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(info.signing_kind, Some(SigningKind::Rsa));
|
||||
assert!(info.any_rsa);
|
||||
}
|
||||
|
||||
/// An Ed25519 key that signs, with an RSA subkey for encryption: an RSA part all the same.
|
||||
#[test]
|
||||
fn an_rsa_encryption_subkey_says_so_too() {
|
||||
let key = secret("Ada <ada@example.org>", Make { rsa_encryption: true, ..Make::default() });
|
||||
let info = read(&key.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(info.signing_kind, Some(SigningKind::EdDsa));
|
||||
assert!(info.any_rsa);
|
||||
let plain = read(&secret("Ada <ada@example.org>", Make::default()).to_bytes().unwrap()).unwrap();
|
||||
assert!(!plain.any_rsa);
|
||||
}
|
||||
|
||||
/// The whole file is kept, so the one key checked must be all there is in it.
|
||||
#[test]
|
||||
fn a_file_with_more_than_one_key_is_refused() {
|
||||
let ada = secret("Ada <ada@example.org>", Make::default());
|
||||
let mut bea = secret("Bea <bea@example.org>", Make { seed: 2, ..Make::default() });
|
||||
protect(&mut bea, true, true);
|
||||
let (a, b) = (ada.to_armored_string(ArmorOptions::default()).unwrap(), bea.to_armored_string(ArmorOptions::default()).unwrap());
|
||||
assert_eq!(read(format!("{b}\n{a}").as_bytes()).err(), Some(Error::SeveralKeys), "two armour blocks");
|
||||
assert_eq!(read(format!("{b}\n{}", armoured_public(&ada)).as_bytes()).err(), Some(Error::SeveralKeys), "a key and another block");
|
||||
let mut both = bea.to_bytes().unwrap();
|
||||
both.extend(ada.to_bytes().unwrap());
|
||||
assert_eq!(read(&both).err(), Some(Error::SeveralKeys), "two binary keys");
|
||||
let mut trailing = bea.to_bytes().unwrap();
|
||||
trailing.extend(b"\0and then something else");
|
||||
assert_eq!(read(&trailing).err(), Some(Error::SeveralKeys), "something after the key");
|
||||
assert!(read(&bea.to_bytes().unwrap()).is_ok());
|
||||
assert!(read(b.as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
/// A passphrase that is empty protects nothing.
|
||||
#[test]
|
||||
fn an_empty_passphrase_is_no_protection() {
|
||||
let mut key = secret("Ada <ada@example.org>", Make { subkey: true, ..Make::default() });
|
||||
let empty = Password::empty();
|
||||
key.primary_key.set_password(StdRng::seed_from_u64(3), &empty).unwrap();
|
||||
for sub in &mut key.secret_subkeys {
|
||||
sub.key.set_password(StdRng::seed_from_u64(4), &empty).unwrap();
|
||||
}
|
||||
assert!(key.primary_key.secret_params().is_encrypted());
|
||||
assert!(!read(&key.to_bytes().unwrap()).unwrap().protected, "it opens with nothing typed");
|
||||
// Only the subkey's empty: still not protected.
|
||||
let mut key = secret("Ada <ada@example.org>", Make { subkey: true, ..Make::default() });
|
||||
protect(&mut key, true, false);
|
||||
for sub in &mut key.secret_subkeys {
|
||||
sub.key.set_password(StdRng::seed_from_u64(4), &empty).unwrap();
|
||||
}
|
||||
assert!(!read(&key.to_bytes().unwrap()).unwrap().protected);
|
||||
for sub in &mut key.secret_subkeys {
|
||||
sub.key.remove_password(&empty).unwrap();
|
||||
}
|
||||
protect(&mut key, false, true);
|
||||
assert!(read(&key.to_bytes().unwrap()).unwrap().protected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_public_key_is_no_secret_key() {
|
||||
let key = secret("Ada <ada@example.org>", Make::default());
|
||||
let public = armoured_public(&key);
|
||||
assert_eq!(read(public.as_bytes()).err(), Some(Error::Unreadable));
|
||||
assert!(!holds_secret(public.as_bytes()));
|
||||
assert_eq!(read(&vec![0x95; crate::keys::MAX_KEY_BYTES + 1]).err(), Some(Error::TooLarge));
|
||||
assert!(read(b"").is_err());
|
||||
}
|
||||
|
||||
/// A secret key wearing a Public key's armour is still told for one.
|
||||
#[test]
|
||||
fn a_secret_key_in_a_public_keys_armour_is_told() {
|
||||
let key = secret("Ada <ada@example.org>", Make::default());
|
||||
let armoured = key.to_armored_string(ArmorOptions::default()).unwrap().replace("PRIVATE KEY BLOCK", "PUBLIC KEY BLOCK");
|
||||
assert!(holds_secret(armoured.as_bytes()));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn any_bytes_read_without_a_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048)) {
|
||||
let _ = read(&bytes);
|
||||
let _ = holds_secret(&bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_secret_key_reads_without_a_panic(at in 0usize..400, byte in any::<u8>()) {
|
||||
static KEY: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| secret("Ada <ada@example.org>", Make::default()).to_bytes().unwrap());
|
||||
let mut bytes = KEY.clone();
|
||||
let len = bytes.len();
|
||||
if let Some(b) = bytes.get_mut(at % len) {
|
||||
*b = byte;
|
||||
}
|
||||
let _ = read(&bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
//! Checking Digital signatures end to end: keys and signatures made here, and RFC 9580's samples.
|
||||
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
|
||||
use crate::fixtures::*;
|
||||
use crate::{CantCheck, Coverage, Fingerprint, KeyRing, NotVerified, Outcome, PublicKey, keys_in_message};
|
||||
|
||||
const ALICE: &str = "Alice <alice@example.org>";
|
||||
|
||||
fn ring(keys: &[&pgp::composed::SignedSecretKey]) -> KeyRing {
|
||||
keys.iter().map(|k| public(k)).collect()
|
||||
}
|
||||
|
||||
fn yes(_: &Fingerprint, _: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn no(_: &Fingerprint, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The outcome alone, for the tests that don't look at what is covered.
|
||||
fn verify(message: &[u8], from: &str, keys: &KeyRing, now: u64, verified: impl Fn(&Fingerprint, &str) -> bool) -> Option<Outcome> {
|
||||
crate::verify(message, from, keys, now, verified).map(|c| c.outcome)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pgp_mime_by_a_verified_key_from_its_address_is_verified() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha256);
|
||||
let outcome = verify(&message, "Alice <alice@example.org>", &ring(&[&alice]), LATER, yes);
|
||||
let Some(Outcome::Verified(signer)) = outcome else { panic!("{outcome:?}") };
|
||||
assert_eq!(&signer.fingerprint, public(&alice).fingerprint());
|
||||
assert_eq!(signer.subkey, None);
|
||||
assert_eq!(signer.addresses, vec!["alice@example.org".to_string()]);
|
||||
assert_eq!(signer.hash, "SHA-256");
|
||||
assert_eq!(signer.algorithm, "EdDSA");
|
||||
assert!(signer.signed > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_not_verified_is_valid_but_not_verified() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha256);
|
||||
let outcome = verify(&message, "alice@example.org", &ring(&[&alice]), LATER, no);
|
||||
let Some(Outcome::Valid { why, .. }) = outcome else { panic!("{outcome:?}") };
|
||||
assert_eq!(why, NotVerified { key_unverified: true, ..NotVerified::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_from_address_not_on_the_key_is_valid_but_not_verified() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha256);
|
||||
let outcome = verify(&message, "Mallory <mallory@example.org>", &ring(&[&alice]), LATER, yes);
|
||||
let Some(Outcome::Valid { why, .. }) = outcome else { panic!("{outcome:?}") };
|
||||
assert_eq!(why, NotVerified { address_differs: true, ..NotVerified::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_part_changed_after_signing_is_changed() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let armour = detached(&alice, SIGNED_PART.as_bytes(), HashAlgorithm::Sha256, false);
|
||||
let message = pgp_mime("alice@example.org", &SIGNED_PART.replace("attached", "lost"), &armour);
|
||||
let outcome = verify(&message, ALICE, &ring(&[&alice]), LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::Changed(_))), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_header_of_the_signed_part_is_changed() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let armour = detached(&alice, SIGNED_PART.as_bytes(), HashAlgorithm::Sha256, false);
|
||||
let message = pgp_mime("alice@example.org", &SIGNED_PART.replace("utf-8", "us-ascii"), &armour);
|
||||
assert!(matches!(verify(&message, ALICE, &ring(&[&alice]), LATER, yes), Some(Outcome::Changed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_public_key_cant_be_checked_and_names_the_signer() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let bob = secret("Bob <bob@example.org>", Make { seed: 2, ..Make::default() });
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha256);
|
||||
let outcome = verify(&message, ALICE, &ring(&[&bob]), LATER, yes);
|
||||
let Some(Outcome::CantCheck { why: CantCheck::NoPublicKey, issuer }) = outcome else { panic!("{outcome:?}") };
|
||||
assert_eq!(issuer.fingerprint.as_ref(), Some(public(&alice).fingerprint()));
|
||||
// rPGP writes the key id in the unhashed area, which anybody can change: not believed.
|
||||
assert_eq!(issuer.key_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_and_md5_are_algorithms_no_longer_safe() {
|
||||
let alice = secret(ALICE, Make { rsa: Some(2048), ..Make::default() });
|
||||
for (hash, name) in [(HashAlgorithm::Sha1, "SHA-1"), (HashAlgorithm::Md5, "MD5")] {
|
||||
let armour = detached(&alice, SIGNED_PART.as_bytes(), hash, false);
|
||||
let message = pgp_mime("alice@example.org", SIGNED_PART, &armour);
|
||||
let outcome = verify(&message, ALICE, &ring(&[&alice]), LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::CantCheck { why: CantCheck::WeakAlgorithm(n), .. }) if n == name), "{outcome:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signing_subkey_is_found_by_its_own_id() {
|
||||
let alice = secret(ALICE, Make { subkey: true, ..Make::default() });
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha512);
|
||||
let outcome = verify(&message, ALICE, &ring(&[&alice]), LATER, yes);
|
||||
let Some(Outcome::Verified(signer)) = outcome else { panic!("{outcome:?}") };
|
||||
assert!(signer.subkey.is_some());
|
||||
assert_ne!(signer.subkey.as_ref(), Some(&signer.fingerprint));
|
||||
assert_eq!(signer.hash, "SHA-512");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_6_keys_check() {
|
||||
let alice = secret(ALICE, Make { v6: true, seed: 3, ..Make::default() });
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha256);
|
||||
let outcome = verify(&message, ALICE, &ring(&[&alice]), LATER, yes);
|
||||
let Some(Outcome::Verified(signer)) = outcome else { panic!("{outcome:?}") };
|
||||
assert_eq!(signer.fingerprint.as_bytes().len(), 32);
|
||||
assert_eq!(signer.algorithm, "Ed25519");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_kept_with_bare_line_feeds_is_checked_in_canonical_form() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let message = String::from_utf8(signed_message(&alice, HashAlgorithm::Sha256)).unwrap().replace("\r\n", "\n");
|
||||
assert!(matches!(verify(message.as_bytes(), ALICE, &ring(&[&alice]), LATER, yes), Some(Outcome::Verified(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_mode_signature_checks() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let armour = detached(&alice, SIGNED_PART.as_bytes(), HashAlgorithm::Sha256, true);
|
||||
let message = pgp_mime("alice@example.org", SIGNED_PART, &armour);
|
||||
assert!(matches!(verify(&message, ALICE, &ring(&[&alice]), LATER, yes), Some(Outcome::Verified(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_message_inside_a_mailing_list_wrapper_is_found_but_only_partly_signed() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let inner = String::from_utf8(signed_message(&alice, HashAlgorithm::Sha256)).unwrap();
|
||||
// The signed message's own Content-Type becomes the first part's header.
|
||||
let (head, body) = inner.split_once("\r\n\r\n").unwrap();
|
||||
let content_type = head.split("\r\n").skip_while(|l| !l.starts_with("Content-Type")).collect::<Vec<_>>().join("\r\n");
|
||||
let wrapped = format!(
|
||||
"From: list@example.org\r\nContent-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n--outer\r\n{content_type}\r\n\r\n{body}\r\n--outer\r\nContent-Type: text/plain\r\n\r\nList footer\r\n--outer--\r\n"
|
||||
);
|
||||
// The footer isn't signed: the signature is good, but the message isn't Verified.
|
||||
let checked = crate::verify(wrapped.as_bytes(), ALICE, &ring(&[&alice]), LATER, yes).unwrap();
|
||||
assert_eq!(checked.coverage, Coverage { whole: false, path: vec![0], span: None });
|
||||
let Outcome::Valid { why, .. } = checked.outcome else { panic!("{checked:?}") };
|
||||
assert_eq!(why, NotVerified { partly_signed: true, ..NotVerified::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_cleartext_signed_text_checks() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
// (Trailing white space, which a reader drops, is in `armour_tests`: rPGP signs it as it is.)
|
||||
let text = "Hello Bob,\n- a line that starts with a dash\n-----\nAlice\n";
|
||||
let message = plain(ALICE, "7bit", &cleartext(&alice, text));
|
||||
assert!(matches!(verify(&message, ALICE, &ring(&[&alice]), LATER, yes), Some(Outcome::Verified(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_text_changed_after_signing_is_changed() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let signed = cleartext(&alice, "Pay 10 EUR to Bob.\n").replace("10 EUR", "99 EUR");
|
||||
let message = plain(ALICE, "7bit", &signed);
|
||||
assert!(matches!(verify(&message, ALICE, &ring(&[&alice]), LATER, yes), Some(Outcome::Changed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_text_in_quoted_printable_and_base64_checks() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let signed = cleartext(&alice, "Gr=C3=BC=C3=9Fe and a long line\n");
|
||||
// Quoted-printable: the `=` of the text is written as `=3D`, and a soft break is added.
|
||||
let qp = signed.replace('=', "=3D").replacen("Gr=3DC3", "Gr=\r\n=3DC3", 1);
|
||||
let message = plain(ALICE, "quoted-printable", &qp);
|
||||
assert!(matches!(verify(&message, ALICE, &ring(&[&alice]), LATER, yes), Some(Outcome::Verified(_))), "qp");
|
||||
let b64 = base64(signed.as_bytes());
|
||||
let message = plain(ALICE, "base64", &b64);
|
||||
assert!(matches!(verify(&message, ALICE, &ring(&[&alice]), LATER, yes), Some(Outcome::Verified(_))), "base64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc9580_sample_cleartext_message_checks_against_the_sample_certificate() {
|
||||
let keys: KeyRing = PublicKey::parse(RFC9580_A3_CERT.as_bytes()).unwrap().into_iter().collect();
|
||||
assert_eq!(keys.keys()[0].fingerprint().to_hex(), RFC9580_A3_FINGERPRINT);
|
||||
// The sample certificate has no User ID, so no address is its own.
|
||||
assert!(keys.keys()[0].addresses().is_empty());
|
||||
let message = plain("someone@example.org", "7bit", RFC9580_A6_CLEARTEXT);
|
||||
let outcome = verify(&message, "someone@example.org", &keys, LATER, yes);
|
||||
let Some(Outcome::Valid { signer, why }) = outcome else { panic!("{outcome:?}") };
|
||||
assert_eq!(why, NotVerified { address_differs: true, ..NotVerified::default() });
|
||||
assert_eq!(signer.algorithm, "Ed25519");
|
||||
// A changed grocery list is a changed message.
|
||||
let changed = plain("someone@example.org", "7bit", &RFC9580_A6_CLEARTEXT.replace("tofu", "tempeh"));
|
||||
assert!(matches!(verify(&changed, "someone@example.org", &keys, LATER, yes), Some(Outcome::Changed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_without_an_openpgp_signature_are_none() {
|
||||
let keys = KeyRing::new();
|
||||
assert_eq!(verify(&plain(ALICE, "7bit", "Just text.\r\n"), ALICE, &keys, LATER, yes), None);
|
||||
let smime = b"From: a@example.org\r\nContent-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; boundary=b\r\n\r\n--b\r\n\r\nx\r\n--b\r\nContent-Type: application/pkcs7-signature\r\n\r\nAAAA\r\n--b--\r\n";
|
||||
assert_eq!(verify(smime, ALICE, &keys, LATER, yes), None);
|
||||
let encrypted =
|
||||
b"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=b\r\n\r\n--b\r\n\r\nVersion: 1\r\n--b--\r\n";
|
||||
assert_eq!(verify(encrypted, ALICE, &keys, LATER, yes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_pgp_mime_layout_cant_be_checked() {
|
||||
let keys = KeyRing::new();
|
||||
let one_part = b"Content-Type: multipart/signed; protocol=\"application/pgp-signature\"; boundary=b\r\n\r\n--b\r\n\r\nx\r\n--b--\r\n";
|
||||
assert!(matches!(verify(one_part, ALICE, &keys, LATER, yes), Some(Outcome::CantCheck { why: CantCheck::Unreadable, .. })));
|
||||
let junk = pgp_mime("alice@example.org", SIGNED_PART, "-----BEGIN PGP SIGNATURE-----\r\n\r\nnot base64 at all!\r\n-----END PGP SIGNATURE-----");
|
||||
assert!(matches!(verify(&junk, ALICE, &keys, LATER, yes), Some(Outcome::CantCheck { why: CantCheck::Unreadable, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_by_another_key_with_the_same_address_is_changed_only_when_named() {
|
||||
// Two keys for one address: the signature names the first, which doesn't match the text.
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let other = secret(ALICE, Make { seed: 5, ..Make::default() });
|
||||
let armour = detached(&alice, b"something else", HashAlgorithm::Sha256, false);
|
||||
let message = pgp_mime("alice@example.org", SIGNED_PART, &armour);
|
||||
assert!(matches!(verify(&message, ALICE, &ring(&[&alice, &other]), LATER, yes), Some(Outcome::Changed(_))));
|
||||
// Without the named key, the other key of the address isn't blamed.
|
||||
assert!(matches!(verify(&message, ALICE, &ring(&[&other]), LATER, yes), Some(Outcome::CantCheck { why: CantCheck::NoMatchingKey, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clock_matters_only_for_what_expires() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha256);
|
||||
// Neither key nor signature expires, so any time the caller gives checks the same.
|
||||
let outcome = verify(&message, ALICE, &ring(&[&alice]), 0, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::Verified(_))), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_read_armoured_or_binary_and_round_trip() {
|
||||
let alice = secret(ALICE, Make { subkey: true, ..Make::default() });
|
||||
let armoured = armoured_public(&alice);
|
||||
let keys = PublicKey::parse(armoured.as_bytes()).unwrap();
|
||||
assert_eq!(keys.len(), 1);
|
||||
let key = &keys[0];
|
||||
assert_eq!(key.addresses(), ["alice@example.org".to_string()]);
|
||||
assert!(key.has_address("ALICE@example.org"));
|
||||
assert!(!key.revoked());
|
||||
assert_eq!(key.expires(), None);
|
||||
let again = PublicKey::parse(&key.to_bytes()).unwrap();
|
||||
assert_eq!(again[0].fingerprint(), key.fingerprint());
|
||||
assert_eq!(PublicKey::parse(b"not a key").unwrap_err(), crate::Error::Unreadable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_whose_user_id_was_altered_has_no_address() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let mut public_key = pgp::composed::SignedPublicKey::from(alice.clone());
|
||||
let user = public_key.details.users[0].clone();
|
||||
let forged = pgp::packet::UserId::from_str(Default::default(), "Mallory <mallory@example.org>").unwrap();
|
||||
public_key.details.users[0] = pgp::types::SignedUser::new(forged, user.signatures.clone());
|
||||
let bytes = pgp::ser::Serialize::to_bytes(&public_key).unwrap();
|
||||
// No self-signature checks, so it is no key at all.
|
||||
assert!(PublicKey::parse(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ring_keeps_one_copy_per_fingerprint_and_finds_keys_by_address() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let bob = secret("Bob <bob@example.org>", Make { seed: 2, ..Make::default() });
|
||||
let mut keys = KeyRing::new();
|
||||
assert!(keys.insert(public(&alice)));
|
||||
assert!(!keys.insert(public(&alice)));
|
||||
assert!(keys.insert(public(&bob)));
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert_eq!(keys.for_address("Bob <BOB@example.org>").count(), 1);
|
||||
assert_eq!(keys.for_address("nobody@example.org").count(), 0);
|
||||
assert!(keys.get(public(&bob).fingerprint()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprints_read_and_write_hex() {
|
||||
let fp = Fingerprint::from_hex(RFC9580_A3_FINGERPRINT).unwrap();
|
||||
assert_eq!(fp.to_hex(), RFC9580_A3_FINGERPRINT);
|
||||
assert_eq!(Fingerprint::from_hex(&fp.grouped()), Some(fp.clone()));
|
||||
assert!(fp.grouped().starts_with("CB18 6C4F "));
|
||||
assert_eq!(Fingerprint::from_hex("0x1234"), None);
|
||||
assert_eq!(Fingerprint::from_hex(&"G".repeat(40)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_keys_and_the_autocrypt_header_are_found() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let carol = secret("Carol <carol@example.org>", Make { seed: 4, ..Make::default() });
|
||||
let keydata = base64(&pgp::ser::Serialize::to_bytes(&pgp::composed::SignedPublicKey::from(alice.clone())).unwrap()).replace("\r\n", "\r\n ");
|
||||
let message = format!(
|
||||
"From: Alice <alice@example.org>\r\nAutocrypt: addr=alice@example.org; prefer-encrypt=mutual; keydata=\r\n {keydata}\r\nContent-Type: multipart/mixed; boundary=m\r\n\r\n--m\r\nContent-Type: text/plain\r\n\r\nCarol's key, as asked.\r\n--m\r\nContent-Type: application/pgp-keys; name=\"carol.asc\"\r\n\r\n{}\r\n--m--\r\n",
|
||||
armoured_public(&carol)
|
||||
);
|
||||
let found = keys_in_message(message.as_bytes());
|
||||
let autocrypt = found.autocrypt.expect("autocrypt");
|
||||
assert_eq!(autocrypt.address, "alice@example.org");
|
||||
assert!(autocrypt.prefer_encrypt_mutual);
|
||||
assert_eq!(autocrypt.key.fingerprint(), public(&alice).fingerprint());
|
||||
assert_eq!(found.attached.len(), 1);
|
||||
assert_eq!(found.attached[0].addresses(), ["carol@example.org".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_autocrypt_header_for_another_address_or_twice_is_ignored() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let keydata = base64(&pgp::ser::Serialize::to_bytes(&pgp::composed::SignedPublicKey::from(alice)).unwrap()).replace("\r\n", "");
|
||||
let header = format!("Autocrypt: addr=alice@example.org; keydata={keydata}\r\n");
|
||||
let other = format!("From: mallory@example.org\r\n{header}\r\nHi\r\n");
|
||||
assert!(keys_in_message(other.as_bytes()).autocrypt.is_none());
|
||||
let twice = format!("From: alice@example.org\r\n{header}{header}\r\nHi\r\n");
|
||||
assert!(keys_in_message(twice.as_bytes()).autocrypt.is_none());
|
||||
let once = format!("From: alice@example.org\r\n{header}\r\nHi\r\n");
|
||||
let found = keys_in_message(once.as_bytes()).autocrypt.expect("one header");
|
||||
assert!(!found.prefer_encrypt_mutual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_revoked_key_cant_be_checked() {
|
||||
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{KeyDetails, Password, Timestamp};
|
||||
use rand::SeedableRng;
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let mut public_key = pgp::composed::SignedPublicKey::from(alice.clone());
|
||||
let mut config = SignatureConfig::from_key(rand::rngs::StdRng::seed_from_u64(11), &alice.primary_key, SignatureType::KeyRevocation).unwrap();
|
||||
config.hashed_subpackets = vec![
|
||||
Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::now())).unwrap(),
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(alice.primary_key.fingerprint())).unwrap(),
|
||||
];
|
||||
let revocation = config.sign_key(&alice.primary_key, &Password::empty(), &public_key.primary_key).unwrap();
|
||||
public_key.details.revocation_signatures.push(revocation);
|
||||
let keys: KeyRing = PublicKey::parse(&pgp::ser::Serialize::to_bytes(&public_key).unwrap()).unwrap().into_iter().collect();
|
||||
assert!(keys.keys()[0].revoked());
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha256);
|
||||
let outcome = verify(&message, ALICE, &keys, LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::CantCheck { why: CantCheck::KeyRevoked, .. })), "{outcome:?}");
|
||||
}
|
||||
|
||||
/// Binary signature packets by `secret`'s signing key over `data`, made at `at`.
|
||||
fn packets(secret: &pgp::composed::SignedSecretKey, data: &[u8], at: u32) -> Vec<u8> {
|
||||
let key = signer(secret);
|
||||
let sig = config(&key, pgp::packet::SignatureType::Binary, at, vec![]).sign(&key, &pgp::types::Password::empty(), data).unwrap();
|
||||
pgp::ser::Serialize::to_bytes(&pgp::composed::DetachedSignature::new(sig)).unwrap()
|
||||
}
|
||||
|
||||
fn context(keys: &KeyRing) -> crate::check::Context<'_> {
|
||||
crate::check::Context {
|
||||
from: "alice@example.org".into(),
|
||||
keys,
|
||||
now: LATER,
|
||||
verified: &|_, _| true,
|
||||
verifications: Default::default(),
|
||||
data_passes: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_tried_for_one_signature_and_one_message_are_capped_and_the_text_hashed_once() {
|
||||
// Twenty keys claim the address; the signature is by another, not here.
|
||||
let keys: KeyRing = (10..30).map(|seed| public(&secret(ALICE, Make { seed, ..Make::default() }))).collect();
|
||||
let stranger = secret(ALICE, Make { seed: 40, ..Make::default() });
|
||||
let t = made(&stranger);
|
||||
let cx = context(&keys);
|
||||
let outcome = crate::check::check(&packets(&stranger, b"data", t), b"data", &cx);
|
||||
assert!(matches!(outcome, Outcome::CantCheck { why: CantCheck::NoMatchingKey, .. }), "{outcome:?}");
|
||||
assert_eq!(cx.verifications.get(), 8, "eight keys a signature");
|
||||
// Eight such signatures together: the message's budget, and one pass over the text.
|
||||
let cx = context(&keys);
|
||||
let many: Vec<u8> = (0..8).flat_map(|i| packets(&stranger, b"data", t + i)).collect();
|
||||
let _ = crate::check::check(&many, b"data", &cx);
|
||||
assert_eq!(cx.verifications.get(), 24, "twenty-four checks a message");
|
||||
assert_eq!(cx.data_passes.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_is_verified_only_for_the_address_it_was_verified_for() {
|
||||
let alice = secret(ALICE, Make { alias: true, ..Make::default() });
|
||||
let fingerprint = public(&alice).fingerprint().clone();
|
||||
let keys = ring(&[&alice]);
|
||||
let only_alice = |fp: &Fingerprint, address: &str| *fp == fingerprint && address == "alice@example.org";
|
||||
let message = signed_message(&alice, HashAlgorithm::Sha256);
|
||||
assert!(matches!(verify(&message, ALICE, &keys, LATER, only_alice), Some(Outcome::Verified(_))));
|
||||
// The key's other address (a User ID added later, say) wasn't the one compared.
|
||||
let outcome = verify(&message, "alias@example.org", &keys, LATER, only_alice);
|
||||
let Some(Outcome::Valid { why, .. }) = outcome else { panic!("{outcome:?}") };
|
||||
assert_eq!(why, NotVerified { key_unverified: true, ..NotVerified::default() });
|
||||
}
|
||||
|
||||
/// A PGP/MIME message whose signature is `sig`, over [`SIGNED_PART`].
|
||||
fn with_signature(sig: pgp::packet::Signature) -> Vec<u8> {
|
||||
let armour = pgp::composed::DetachedSignature::new(sig).to_armored_string(Default::default()).unwrap();
|
||||
pgp_mime("alice@example.org", SIGNED_PART, &armour)
|
||||
}
|
||||
|
||||
fn two_mpis() -> pgp::types::SignatureBytes {
|
||||
pgp::types::SignatureBytes::Mpis(vec![pgp::types::Mpi::from_slice(&[1; 32]), pgp::types::Mpi::from_slice(&[1; 32])])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsa_and_version_3_signatures_are_refused() {
|
||||
use pgp::crypto::public_key::PublicKeyAlgorithm;
|
||||
use pgp::packet::{Signature, SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{KeyDetails, Timestamp};
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let keys = ring(&[&alice]);
|
||||
let mut dsa = SignatureConfig::v4(SignatureType::Binary, PublicKeyAlgorithm::DSA, HashAlgorithm::Sha256);
|
||||
dsa.hashed_subpackets = vec![Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::from_secs(made(&alice)))).unwrap()];
|
||||
let outcome = verify(&with_signature(Signature::from_config(dsa, [0, 0], two_mpis()).unwrap()), ALICE, &keys, LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::CantCheck { why: CantCheck::WeakAlgorithm("DSA"), .. })), "{outcome:?}");
|
||||
let v3 = SignatureConfig::v3(
|
||||
SignatureType::Binary,
|
||||
PublicKeyAlgorithm::EdDSALegacy,
|
||||
HashAlgorithm::Sha256,
|
||||
Timestamp::from_secs(made(&alice)),
|
||||
alice.primary_key.legacy_key_id(),
|
||||
);
|
||||
let outcome = verify(&with_signature(Signature::from_config(v3, [0, 0], two_mpis()).unwrap()), ALICE, &keys, LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::CantCheck { why: CantCheck::UnknownAlgorithm, .. })), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_unhashed_area_never_decides_who_is_blamed() {
|
||||
use pgp::packet::{Subpacket, SubpacketData};
|
||||
use pgp::types::{KeyDetails, Password};
|
||||
let mallory = secret("Mallory <mallory@example.org>", Make { seed: 6, ..Make::default() });
|
||||
let bob = secret("Bob <bob@example.org>", Make { seed: 2, ..Make::default() });
|
||||
let keys = ring(&[&bob]);
|
||||
let t = made(&mallory);
|
||||
let sign = |unhashed: Vec<SubpacketData>| {
|
||||
let mut config = config(&mallory.primary_key, pgp::packet::SignatureType::Binary, t, vec![]);
|
||||
config.unhashed_subpackets = unhashed.into_iter().map(|d| Subpacket::regular(d).unwrap()).collect();
|
||||
with_signature(config.sign(&mallory.primary_key, &Password::empty(), SIGNED_PART.as_bytes()).unwrap())
|
||||
};
|
||||
// Mallory's signature, its unhashed area naming Bob: Bob isn't blamed for it.
|
||||
let framed = verify(&sign(vec![SubpacketData::IssuerFingerprint(bob.primary_key.fingerprint())]), ALICE, &keys, LATER, yes);
|
||||
assert!(matches!(framed, Some(Outcome::CantCheck { why: CantCheck::NoPublicKey, .. })), "{framed:?}");
|
||||
assert_eq!(framed, verify(&sign(vec![]), ALICE, &keys, LATER, yes));
|
||||
|
||||
// An issuer only in the unhashed area finds a key, which counts because it fits.
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let mut config = config(&alice.primary_key, pgp::packet::SignatureType::Binary, t, vec![]);
|
||||
config.hashed_subpackets.retain(|p| matches!(p.data, SubpacketData::SignatureCreationTime(_)));
|
||||
config.unhashed_subpackets = vec![Subpacket::regular(SubpacketData::IssuerFingerprint(alice.primary_key.fingerprint())).unwrap()];
|
||||
let sig = config.sign(&alice.primary_key, &Password::empty(), SIGNED_PART.as_bytes()).unwrap();
|
||||
let outcome = verify(&with_signature(sig), "Carol <carol@example.org>", &ring(&[&alice, &bob]), LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::Valid { .. })), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_signature_is_unreadable_not_changed() {
|
||||
use pgp::packet::Signature;
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let config = config(&alice.primary_key, pgp::packet::SignatureType::Binary, made(&alice), vec![]);
|
||||
// Legacy EdDSA has two MPIs; one is no signature of its kind.
|
||||
let one = pgp::types::SignatureBytes::Mpis(vec![pgp::types::Mpi::from_slice(&[1; 32])]);
|
||||
let outcome = verify(&with_signature(Signature::from_config(config, [0, 0], one).unwrap()), ALICE, &ring(&[&alice]), LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::CantCheck { why: CantCheck::Unreadable, .. })), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocrypt_in_a_report_is_ignored() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let keydata = base64(&pgp::ser::Serialize::to_bytes(&pgp::composed::SignedPublicKey::from(alice)).unwrap()).replace("\r\n", "");
|
||||
let message = |media: &str| {
|
||||
format!(
|
||||
"From: alice@example.org\r\nAutocrypt: addr=alice@example.org; keydata={keydata}\r\nContent-Type: {media}; boundary=r\r\n\r\n--r\r\n\r\nHi\r\n--r--\r\n"
|
||||
)
|
||||
};
|
||||
assert!(keys_in_message(message("multipart/mixed").as_bytes()).autocrypt.is_some());
|
||||
assert!(keys_in_message(message("multipart/report").as_bytes()).autocrypt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_from_field_with_two_mailboxes_matches_no_address() {
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let two = "alice@example.org, Mallory <mallory@example.org>";
|
||||
let outcome = verify(&signed_message(&alice, HashAlgorithm::Sha256), two, &ring(&[&alice]), LATER, yes);
|
||||
let Some(Outcome::Valid { why, .. }) = outcome else { panic!("{outcome:?}") };
|
||||
assert_eq!(why, NotVerified { key_unverified: true, address_differs: true, partly_signed: false });
|
||||
let keydata = base64(&pgp::ser::Serialize::to_bytes(&pgp::composed::SignedPublicKey::from(alice)).unwrap()).replace("\r\n", "");
|
||||
let message = format!("From: {two}\r\nAutocrypt: addr=alice@example.org; keydata={keydata}\r\n\r\nHi\r\n");
|
||||
assert!(keys_in_message(message.as_bytes()).autocrypt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_value_of_the_wrong_shape_is_unreadable() {
|
||||
// rPGP reads an Ed448 signature as whatever bytes are left; 10 aren't one.
|
||||
use pgp::crypto::public_key::PublicKeyAlgorithm;
|
||||
use pgp::packet::{Signature, SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{KeyDetails, Timestamp};
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let mut config = SignatureConfig::v4(SignatureType::Binary, PublicKeyAlgorithm::Ed448, HashAlgorithm::Sha512);
|
||||
config.hashed_subpackets = vec![
|
||||
Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::from_secs(made(&alice)))).unwrap(),
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(alice.primary_key.fingerprint())).unwrap(),
|
||||
];
|
||||
let sig = Signature::from_config(config, [0, 0], pgp::types::SignatureBytes::Native(vec![1u8; 10].into())).unwrap();
|
||||
let outcome = verify(&with_signature(sig), ALICE, &KeyRing::new(), LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::CantCheck { why: CantCheck::Unreadable, .. })), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_claiming_the_address_cant_push_out_the_key_the_signature_hints_at() {
|
||||
use pgp::packet::{Subpacket, SubpacketData};
|
||||
use pgp::types::{KeyDetails, Password};
|
||||
let alice = secret(ALICE, Make::default());
|
||||
// Eight keys claim Alice's address, and come before hers.
|
||||
let mut keys: KeyRing = (50..58).map(|seed| public(&secret(ALICE, Make { seed, ..Make::default() }))).collect();
|
||||
keys.insert(public(&alice));
|
||||
// Her signature names her only in the unhashed area, as some writers do.
|
||||
let mut config = config(&alice.primary_key, pgp::packet::SignatureType::Binary, made(&alice), vec![]);
|
||||
config.hashed_subpackets.retain(|p| matches!(p.data, SubpacketData::SignatureCreationTime(_)));
|
||||
config.unhashed_subpackets = vec![Subpacket::regular(SubpacketData::IssuerFingerprint(alice.primary_key.fingerprint())).unwrap()];
|
||||
let sig = config.sign(&alice.primary_key, &Password::empty(), SIGNED_PART.as_bytes()).unwrap();
|
||||
let outcome = verify(&with_signature(sig), ALICE, &keys, LATER, yes);
|
||||
assert!(matches!(outcome, Some(Outcome::Verified(_))), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_out_of_checks_before_the_signers_key_is_tried_is_unreadable() {
|
||||
// Earlier signatures of the message used up its checks: whether this one is good isn't known.
|
||||
let alice = secret(ALICE, Make::default());
|
||||
let keys = ring(&[&alice]);
|
||||
let cx = context(&keys);
|
||||
cx.verifications.set(crate::check::MAX_VERIFICATIONS);
|
||||
let outcome = crate::check::check(&packets(&alice, b"data", made(&alice)), b"data", &cx);
|
||||
assert!(matches!(outcome, Outcome::CantCheck { why: CantCheck::Unreadable, .. }), "{outcome:?}");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! A key and a PGP/MIME signature put together byte by byte from RFC 9580 (§4.2 packet headers,
|
||||
//! §5.2 signatures, §5.5 keys, §5.2.4 what is hashed), with Ed25519 and SHA-256 and nothing of
|
||||
//! rPGP's writing: so the checks here don't only agree with the library that made the samples.
|
||||
|
||||
use ed25519_dalek::Signer;
|
||||
use sha1::Digest as _;
|
||||
|
||||
use crate::fixtures::{LATER, SIGNED_PART, base64, pgp_mime};
|
||||
use crate::{KeyRing, Outcome, PublicKey, verify};
|
||||
|
||||
/// When the key and the signatures were made (2024-01-01).
|
||||
const MADE: u32 = 1_704_067_200;
|
||||
const USER_ID: &[u8] = b"Hand <hand@example.org>";
|
||||
|
||||
/// A packet: a new-format header (§4.2.1) with the tag, its length, then `body`.
|
||||
fn packet(tag: u8, body: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0xC0 | tag];
|
||||
match body.len() {
|
||||
n @ 0..=191 => out.push(n as u8),
|
||||
n => {
|
||||
let n = n - 192;
|
||||
out.extend_from_slice(&[(n >> 8) as u8 + 192, (n & 0xFF) as u8]);
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
}
|
||||
|
||||
struct Hand {
|
||||
secret: ed25519_dalek::SigningKey,
|
||||
/// The version 4 public-key packet's body (§5.5.2): version, time, algorithm 27, the key.
|
||||
key_body: Vec<u8>,
|
||||
fingerprint: [u8; 20],
|
||||
}
|
||||
|
||||
impl Hand {
|
||||
fn new() -> Hand {
|
||||
let secret = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
|
||||
let mut key_body = vec![4];
|
||||
key_body.extend_from_slice(&MADE.to_be_bytes());
|
||||
key_body.push(27);
|
||||
key_body.extend_from_slice(&secret.verifying_key().to_bytes());
|
||||
// §5.5.4.2: SHA-1 over 0x99, the two-octet length and the body.
|
||||
let mut sha1 = sha1::Sha1::new();
|
||||
sha1.update([0x99, 0, key_body.len() as u8]);
|
||||
sha1.update(&key_body);
|
||||
Hand { secret, fingerprint: sha1.finalize().into(), key_body }
|
||||
}
|
||||
|
||||
/// A version 4 signature packet of `typ` over `prefix` (what comes before the signature's
|
||||
/// own fields in the hash), SHA-256, its time and issuer fingerprint hashed, `extra` too.
|
||||
fn signature(&self, typ: u8, prefix: &[u8], extra: &[u8]) -> Vec<u8> {
|
||||
let mut hashed = vec![5, 2];
|
||||
hashed.extend_from_slice(&MADE.to_be_bytes());
|
||||
hashed.extend_from_slice(&[22, 33, 4]);
|
||||
hashed.extend_from_slice(&self.fingerprint);
|
||||
hashed.extend_from_slice(extra);
|
||||
let mut fields = vec![4, typ, 27, 8, 0, hashed.len() as u8];
|
||||
fields.extend_from_slice(&hashed);
|
||||
// §5.2.4: the fields, then 0x04 0xFF and their length in four octets.
|
||||
let mut sha = sha2::Sha256::new();
|
||||
sha.update(prefix);
|
||||
sha.update(&fields);
|
||||
sha.update([4, 0xFF]);
|
||||
sha.update((fields.len() as u32).to_be_bytes());
|
||||
let digest = sha.finalize();
|
||||
let mut body = fields;
|
||||
body.extend_from_slice(&[0, 0]);
|
||||
body.extend_from_slice(&digest[..2]);
|
||||
// §5.2.3.4: Ed25519 signs the digest, and the signature is its 64 octets as they are.
|
||||
body.extend_from_slice(&self.secret.sign(&digest).to_bytes());
|
||||
packet(2, &body)
|
||||
}
|
||||
|
||||
/// The certificate: key, User ID, and a positive certification that may sign and certify.
|
||||
fn certificate(&self) -> String {
|
||||
let mut prefix = vec![0x99, 0, self.key_body.len() as u8];
|
||||
prefix.extend_from_slice(&self.key_body);
|
||||
prefix.push(0xB4);
|
||||
prefix.extend_from_slice(&(USER_ID.len() as u32).to_be_bytes());
|
||||
prefix.extend_from_slice(USER_ID);
|
||||
let mut bytes = packet(6, &self.key_body);
|
||||
bytes.extend(packet(13, USER_ID));
|
||||
bytes.extend(self.signature(0x13, &prefix, &[2, 27, 0x03]));
|
||||
format!("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n{}\n-----END PGP PUBLIC KEY BLOCK-----\n", base64(&bytes))
|
||||
}
|
||||
|
||||
/// A PGP/MIME message whose signature is over [`SIGNED_PART`] (a binary signature, §5.2.1.1).
|
||||
fn message(&self) -> Vec<u8> {
|
||||
let sig = self.signature(0x00, SIGNED_PART.as_bytes(), &[]);
|
||||
pgp_mime("hand@example.org", SIGNED_PART, &format!("-----BEGIN PGP SIGNATURE-----\n\n{}\n-----END PGP SIGNATURE-----", base64(&sig)))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_and_signature_made_by_hand_from_the_specification_check() {
|
||||
let hand = Hand::new();
|
||||
let keys: KeyRing = PublicKey::parse(hand.certificate().as_bytes()).unwrap().into_iter().collect();
|
||||
let key = &keys.keys()[0];
|
||||
assert_eq!(key.fingerprint().as_bytes(), hand.fingerprint);
|
||||
assert_eq!(key.addresses(), ["hand@example.org".to_string()]);
|
||||
let yes = |_: &crate::Fingerprint, _: &str| true;
|
||||
let message = hand.message();
|
||||
let checked = verify(&message, "hand@example.org", &keys, LATER, yes).unwrap();
|
||||
let Outcome::Verified(signer) = checked.outcome else { panic!("{checked:?}") };
|
||||
assert_eq!((signer.signed, signer.algorithm, signer.hash), (u64::from(MADE), "Ed25519", "SHA-256"));
|
||||
// Kept with bare line feeds, it is checked in canonical form all the same.
|
||||
let lf = String::from_utf8(message.clone()).unwrap().replace("\r\n", "\n");
|
||||
assert!(matches!(verify(lf.as_bytes(), "hand@example.org", &keys, LATER, yes).unwrap().outcome, Outcome::Verified(_)));
|
||||
let changed = String::from_utf8(message).unwrap().replace("minutes", "invoice");
|
||||
assert!(matches!(verify(changed.as_bytes(), "hand@example.org", &keys, LATER, yes).unwrap().outcome, Outcome::Changed(_)));
|
||||
}
|
||||
Reference in new issue
Block a user