Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/dkim.rs

40.0 KiB, 172 runs

created by r1870400018:9838, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! DKIM (DomainKeys Identified Mail) signer.
2//!
3//! Implements the slice of RFC 6376 + RFC 8463 needed to sign outbound
4//! messages with **ed25519-sha256** or **rsa-sha256**, using
5//! `relaxed/relaxed` canonicalisation. Verification is intentionally not
6//! implemented: Hematite signs outbound mail, it does not filter inbound
7//! mail by DKIM.
8//!
9//! Sign with **both**, under two selectors. RFC 8463 §5 says a signer SHOULD,
10//! and the reason is practical: ed25519 verification is still patchy in the
11//! wild, and a receiver that cannot verify a signature sees an *unsigned*
12//! message, leaving DMARC to rest on SPF alone. RSA is understood by
13//! everybody. Two signatures cost a few hundred bytes and let each receiver
14//! take whichever it knows. See [`DkimKey`] for why the RSA key is loaded
15//! rather than generated.
16//!
17//! The output is the input message with a single `DKIM-Signature:`
18//! header field prepended. The original CRLF line-ending convention is
19//! preserved.
20
21//! # Base64
22//!
23//! The `b=`, `bh=` and `p=` tags are RFC 4648 §4 base64, which is what
24//! [`oxedyne_fe2o3_text::base64`] speaks. That decoder refuses whitespace, and a
25//! `b=` tag arrives folded across lines, so anything read back out of a header
26//! must be unfolded before it is handed over. The signer itself only ever
27//! encodes.
28//!
29//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
30//! Anthropic Claude
31
32use crate::email::header::{
33 header_fields,
34 split_headers_body,
35};
36
37use oxedyne_fe2o3_core::prelude::*;
38use oxedyne_fe2o3_text::base64;
39
40use ring::{
41 digest::{
42 digest as sha,
43 SHA256,
44 },
45 rand::SystemRandom,
46 signature::{
47 Ed25519KeyPair,
48 KeyPair,
49 RsaKeyPair,
50 RSA_PKCS1_SHA256,
51 },
52};
53
54
55/// The signing algorithm behind a [`DkimSigner`].
56///
57/// # Why RSA is loaded, never generated
58///
59/// `ring` deliberately refuses to *generate* RSA keys -- it takes the view
60/// that key generation is dangerous and belongs in dedicated tools -- but it
61/// signs with an existing one perfectly well. So the key is generated once,
62/// offline, with the `openssl` command line:
63///
64/// ```text
65/// openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
66/// -outform DER -out dkim_rsa.key
67/// ```
68///
69/// and Steel loads it. The alternative -- implementing RSA signing on top of
70/// a bignum -- means hand-writing a modular exponentiation over a private
71/// exponent, which is precisely the code that leaks its secret through
72/// timing if you get it wrong. Loading an audited implementation is not a
73/// compromise of the no-dependency rule: `ring` is already in the tree, and
74/// is where every other primitive here comes from.
75pub enum DkimKey {
76 Ed25519(Ed25519KeyPair), // ed25519-sha256, RFC 8463; generated in tree
77 Rsa(Box<RsaKeyPair>), // rsa-sha256, RFC 6376; loaded, never generated
78}
79
80impl DkimKey {
81 /// The value of the DKIM `a=` tag for this key.
82 pub fn algorithm(&self) -> &'static str {
83 match self {
84 Self::Ed25519(_) => "ed25519-sha256",
85 Self::Rsa(_) => "rsa-sha256",
86 }
87 }
88
89 /// The value of the DNS `k=` tag for this key.
90 pub fn key_type(&self) -> &'static str {
91 match self {
92 Self::Ed25519(_) => "ed25519",
93 Self::Rsa(_) => "rsa",
94 }
95 }
96}
97
98
99// The headers covered when the caller names none. It mirrors the "well-known"
100// minimum every reputable DKIM implementation oversigns.
101pub const DEFAULT_SIGNED_HEADERS: &[&str] = &[
102 "From",
103 "To",
104 "Cc",
105 "Subject",
106 "Date",
107 "Message-ID",
108 "Reply-To",
109 "MIME-Version",
110 "Content-Type",
111 "Content-Transfer-Encoding",
112];
113
114
115/// One DKIM signing identity: the key, the PKCS#8 bytes it was loaded from so
116/// that it can be persisted and reloaded, the signing domain, and the selector
117/// under which the matching public key is published in DNS.
118pub struct DkimSigner {
119 pkcs8: Vec<u8>,
120 key: DkimKey,
121 domain: String,
122 selector: String,
123}
124
125impl std::fmt::Debug for DkimSigner {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 f.debug_struct("DkimSigner")
128 .field("pkcs8", &"<redacted>")
129 .field("key", &self.key.algorithm())
130 .field("domain", &self.domain)
131 .field("selector", &self.selector)
132 .finish()
133 }
134}
135
136impl DkimSigner {
137 /// Generate a fresh ed25519 key pair for `domain` published under
138 /// `selector`. The resulting signer can be serialised to disk via
139 /// [`DkimSigner::pkcs8_bytes`] and reloaded with
140 /// [`DkimSigner::from_pkcs8`].
141 ///
142 /// There is no RSA equivalent, because `ring` will not generate RSA
143 /// keys. Generate one offline with `openssl` and load it -- see
144 /// [`DkimKey`].
145 pub fn generate(domain: impl Into<String>, selector: impl Into<String>) -> Outcome<Self> {
146 let rng = SystemRandom::new();
147 let pkcs8 = match Ed25519KeyPair::generate_pkcs8(&rng) {
148 Ok(doc) => doc.as_ref().to_vec(),
149 Err(_) => return Err(err!(
150 "Ed25519KeyPair::generate_pkcs8 failed.";
151 Init, Unknown)),
152 };
153 Self::from_pkcs8(&pkcs8, domain, selector)
154 }
155
156 /// Load a private key and work out what it is.
157 ///
158 /// Accepts an ed25519 PKCS#8 key, an RSA PKCS#8 key, or a bare PKCS#1
159 /// RSA key, and selects the signing algorithm accordingly. The operator
160 /// points the config at a key file; they should not also have to tell
161 /// Steel what kind of key they just gave it, when the bytes say so.
162 pub fn from_pkcs8(
163 pkcs8: &[u8],
164 domain: impl Into<String>,
165 selector: impl Into<String>,
166 )
167 -> Outcome<Self>
168 {
169 let key = if let Ok(kp) = Ed25519KeyPair::from_pkcs8(pkcs8) {
170 DkimKey::Ed25519(kp)
171 } else if let Ok(kp) = RsaKeyPair::from_pkcs8(pkcs8) {
172 DkimKey::Rsa(Box::new(kp))
173 } else if let Ok(kp) = RsaKeyPair::from_der(pkcs8) {
174 // A bare PKCS#1 RSAPrivateKey, which is what older openssl
175 // invocations emit.
176 DkimKey::Rsa(Box::new(kp))
177 } else {
178 return Err(err!(
179 "The supplied {} bytes are not an ed25519 PKCS#8 key, an RSA \
180 PKCS#8 key, or a PKCS#1 RSA key. Generate an RSA DKIM key with \
181 `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
182 -outform DER -out dkim_rsa.key`.",
183 pkcs8.len();
184 Init, Invalid, Input));
185 };
186 Ok(Self {
187 pkcs8: pkcs8.to_vec(),
188 key,
189 domain: domain.into(),
190 selector: selector.into(),
191 })
192 }
193
194 pub fn pkcs8_bytes(&self) -> &[u8] { &self.pkcs8 }
195
196 /// The same key and selector, signing for another domain.
197 ///
198 /// A DKIM key is not bound to a domain: what binds them is the public key published at
199 /// `<selector>._domainkey.<domain>`. A host serving several domains signs each one's mail as
200 /// itself from one key, by publishing that key under each domain -- and it must, because a
201 /// signature whose `d=` does not match the From address is unaligned, and an unaligned
202 /// signature is worth very little to the receiver deciding whether to believe the message.
203 ///
204 /// The caller is responsible for having published the key under the domain it names here. A
205 /// signature for a domain with no record is worse than none: it fails rather than being absent.
206 pub fn for_domain(&self, domain: impl Into<String>) -> Outcome<Self> {
207 Self::from_pkcs8(&self.pkcs8, domain, self.selector.clone())
208 }
209
210 pub fn domain(&self) -> &str { &self.domain }
211
212 /// The public key is published at `<selector>._domainkey.<domain>` in DNS.
213 pub fn selector(&self) -> &str { &self.selector }
214
215 /// As it appears in the `a=` tag.
216 pub fn algorithm(&self) -> &'static str { self.key.algorithm() }
217
218 /// The value to publish at `<selector>._domainkey.<domain>`: the whole
219 /// `v=DKIM1; k=<type>; p=<base64>` string, unquoted.
220 ///
221 /// For RSA the published key is a `SubjectPublicKeyInfo`, which is what
222 /// RFC 6376 calls for and what `openssl rsa -pubout` emits. `ring` hands
223 /// back the bare PKCS#1 `RSAPublicKey`, so it is wrapped here.
224 pub fn dns_txt_record(&self) -> String {
225 let (k, p) = match &self.key {
226 DkimKey::Ed25519(kp) => (
227 "ed25519",
228 base64::encode(kp.public_key().as_ref()),
229 ),
230 DkimKey::Rsa(kp) => (
231 "rsa",
232 base64::encode(&rsa_spki_der(kp.public_key().as_ref())),
233 ),
234 };
235 fmt!("v=DKIM1; k={}; p={}", k, p)
236 }
237
238 /// # The two algorithms want different things, and it matters
239 ///
240 /// RFC 6376 §3.7 defines *the message hash*: SHA-256 over the
241 /// canonicalised headers. What each algorithm then does with it differs,
242 /// and conflating them produces a signature that is cryptographically
243 /// impeccable and that no receiver on earth will accept.
244 ///
245 /// **ed25519-sha256** (RFC 8463 §3) signs *the hash*: PureEdDSA over the
246 /// 32-byte SHA-256 digest. Ed25519 hashes its input again internally, with
247 /// SHA-512, so handing it the canonical bytes instead of their digest
248 /// signs an entirely different message. That is what Steel used to do, and
249 /// it is why Gmail reported `dkim=fail` on every message this stack ever
250 /// sent -- silently, because a failed signature is indistinguishable from
251 /// no signature, and the mail still arrived on SPF alone.
252 ///
253 /// **rsa-sha256** takes the message, not the digest: `ring`'s
254 /// `RSA_PKCS1_SHA256` computes the SHA-256 itself and wraps it in the
255 /// PKCS#1 DigestInfo. Pre-hashing here would hash it twice.
256 fn sign_canonical(&self, canon: &[u8]) -> Outcome<Vec<u8>> {
257 match &self.key {
258 DkimKey::Ed25519(kp) => {
259 let digest = sha(&SHA256, canon);
260 Ok(kp.sign(digest.as_ref()).as_ref().to_vec())
261 }
262 DkimKey::Rsa(kp) => {
263 let rng = SystemRandom::new();
264 let mut sig = vec![0u8; kp.public_modulus_len()];
265 match kp.sign(&RSA_PKCS1_SHA256, &rng, canon, &mut sig) {
266 Ok(()) => Ok(sig),
267 Err(_) => Err(err!(
268 "RSA signing failed for DKIM selector '{}' on domain \
269 '{}'.", self.selector, self.domain;
270 Encrypt, Invalid)),
271 }
272 }
273 }
274 }
275
276 /// The exact bytes this signer will sign: the canonicalised header block
277 /// with the `DKIM-Signature` field appended, its `b=` tag empty.
278 ///
279 /// Public because when a receiver rejects a signature, the only question
280 /// worth asking is what was actually signed, and without this the answer
281 /// is buried. Also what the tests hand to an independent verifier.
282 pub fn signing_input(
283 &self,
284 message: &[u8],
285 headers_to_sign: &[&str],
286 timestamp: u64,
287 )
288 -> Outcome<String>
289 {
290 let (canon, _) = res!(self.prepare(message, headers_to_sign, timestamp));
291 Ok(canon)
292 }
293
294 /// Canonicalise, returning the signing input and the pieces the header
295 /// line is built from: `(canon, (bh_b64, h_tag))`.
296 fn prepare(
297 &self,
298 message: &[u8],
299 headers_to_sign: &[&str],
300 timestamp: u64,
301 )
302 -> Outcome<(String, (String, String))>
303 {
304 let names: Vec<&str> = if headers_to_sign.is_empty() {
305 DEFAULT_SIGNED_HEADERS.to_vec()
306 } else {
307 headers_to_sign.to_vec()
308 };
309
310 let (raw_headers, body) = split_headers_body(message);
311 // A header the submission server could not read is not signed either: a receiver would
312 // canonicalise other fields than these.
313 let parsed_headers = res!(header_fields(raw_headers));
314
315 let body_canon = canonicalise_body_relaxed(body);
316 let body_hash = sha(&SHA256, &body_canon);
317 let bh_b64 = base64::encode(body_hash.as_ref());
318
319 let mut covered: Vec<(&str, &str)> = Vec::new();
320 for name in &names {
321 if let Some((_, value)) = parsed_headers.iter().rev()
322 .find(|(n, _)| n.eq_ignore_ascii_case(name))
323 {
324 covered.push((name, value.as_str()));
325 }
326 }
327
328 let algo = self.key.algorithm();
329 let h_tag = covered.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(":");
330 let dkim_value_no_b = fmt!(
331 "v=1; a={}; c=relaxed/relaxed; d={}; s={}; t={}; \
332 bh={}; h={}; b=",
333 algo,
334 self.domain,
335 self.selector,
336 timestamp,
337 bh_b64,
338 h_tag,
339 );
340
341 let mut canon = String::new();
342 for (name, value) in &covered {
343 canon.push_str(&relaxed_header(name, value));
344 }
345 canon.push_str("dkim-signature:");
346 canon.push_str(&relaxed_value(&dkim_value_no_b));
347 // No CRLF on the DKIM-Signature line per RFC 6376 §3.7.
348
349 Ok((canon, (bh_b64, h_tag)))
350 }
351
352 /// A fresh buffer with the `DKIM-Signature:` header prepended; `message`
353 /// itself is not mutated. An empty `headers_to_sign` means
354 /// [`DEFAULT_SIGNED_HEADERS`], otherwise the names are covered in the order
355 /// given.
356 pub fn sign(
357 &self,
358 message: &[u8],
359 headers_to_sign: &[&str],
360 timestamp: u64,
361 )
362 -> Outcome<Vec<u8>>
363 {
364 let (canon, (bh_b64, h_tag)) =
365 res!(self.prepare(message, headers_to_sign, timestamp));
366 let algo = self.key.algorithm();
367
368 let sig = res!(self.sign_canonical(canon.as_bytes()));
369 let b_b64 = base64::encode(&sig);
370
371 // Assemble the final DKIM-Signature header line, folded so no
372 // single line exceeds 78 characters where reasonable.
373 let final_value = fmt!(
374 "v=1; a={}; c=relaxed/relaxed; d={}; s={}; t={};\r\n\
375 \tbh={};\r\n\
376 \th={};\r\n\
377 \tb={}",
378 algo,
379 self.domain,
380 self.selector,
381 timestamp,
382 bh_b64,
383 h_tag,
384 b_b64,
385 );
386 let header_line = fmt!("DKIM-Signature: {}\r\n", final_value);
387
388 // Prepend. The message itself is untouched, and a second signer may
389 // prepend its own header to this output: the covered headers and the
390 // body are unchanged, so the two signatures are independent.
391 let mut out = Vec::with_capacity(header_line.len() + message.len());
392 out.extend_from_slice(header_line.as_bytes());
393 out.extend_from_slice(message);
394 Ok(out)
395 }
396}
397
398
399// ┌───────────────────────────────────────────────────────────────────────────┐
400// │ MESSAGE PARSING + CANONICALISATION │
401// └───────────────────────────────────────────────────────────────────────────┘
402
403/// RFC 6376 §3.4.4.
404fn canonicalise_body_relaxed(body: &[u8]) -> Vec<u8> {
405 let text = String::from_utf8_lossy(body);
406 let mut out: Vec<String> = Vec::new();
407 for raw_line in text.split('\n') {
408 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
409 // Collapse runs of WSP to one SP.
410 let mut collapsed = String::with_capacity(line.len());
411 let mut in_ws = false;
412 for ch in line.chars() {
413 if ch == ' ' || ch == '\t' {
414 if !in_ws {
415 collapsed.push(' ');
416 in_ws = true;
417 }
418 } else {
419 collapsed.push(ch);
420 in_ws = false;
421 }
422 }
423 // Strip trailing WSP.
424 while collapsed.ends_with(' ') {
425 collapsed.pop();
426 }
427 out.push(collapsed);
428 }
429 // Trim trailing empty lines.
430 while out.last().map(|s| s.is_empty()).unwrap_or(false) {
431 out.pop();
432 }
433 let mut bytes = Vec::new();
434 for (i, line) in out.iter().enumerate() {
435 if i > 0 {
436 bytes.extend_from_slice(b"\r\n");
437 }
438 bytes.extend_from_slice(line.as_bytes());
439 }
440 if !bytes.is_empty() {
441 bytes.extend_from_slice(b"\r\n");
442 }
443 bytes
444}
445
446/// RFC 6376 §3.4.2 relaxed: `lcname:relaxedvalue\r\n`.
447fn relaxed_header(name: &str, value: &str) -> String {
448 fmt!("{}:{}\r\n", name.to_lowercase(), relaxed_value(value))
449}
450
451/// Unfolds, collapses every run of WSP to one SP, and strips the leading and
452/// trailing WSP.
453fn relaxed_value(value: &str) -> String {
454 // Unfold: replace every CRLF (or bare LF) followed by WSP with a
455 // single SP, then collapse all runs of WSP to one SP.
456 let unfolded = value.replace("\r\n", "\n");
457 let mut out = String::with_capacity(unfolded.len());
458 let mut prev_ws = false;
459 for ch in unfolded.chars() {
460 if ch == '\n' {
461 // Treat raw line breaks as a folding boundary -- collapse
462 // to SP.
463 if !prev_ws {
464 out.push(' ');
465 prev_ws = true;
466 }
467 } else if ch == ' ' || ch == '\t' {
468 if !prev_ws {
469 out.push(' ');
470 prev_ws = true;
471 }
472 } else {
473 out.push(ch);
474 prev_ws = false;
475 }
476 }
477 out.trim().to_string()
478}
479
480
481// ┌───────────────────────────────────────────────────────────────────────────┐
482// │ RSA PUBLIC KEY ENCODING │
483// └───────────────────────────────────────────────────────────────────────────┘
484
485// DER `AlgorithmIdentifier` for `rsaEncryption` with the ASN.1 NULL parameter:
486// `SEQUENCE { OID 1.2.840.113549.1.1.1, NULL }`.
487const RSA_ALG_ID_DER: [u8; 15] = [
488 0x30, 0x0d, // SEQUENCE, 13 bytes
489 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, // OID rsaEncryption
490 0x01, 0x01, 0x01,
491 0x05, 0x00, // NULL
492];
493
494/// Wrap a PKCS#1 `RSAPublicKey` in a `SubjectPublicKeyInfo`.
495///
496/// DKIM publishes the RSA public key as a `SubjectPublicKeyInfo` (RFC 6376
497/// §3.6.1, by reference to RFC 5280) -- the same thing `openssl rsa -pubout`
498/// writes. `ring` hands back the bare PKCS#1 `RSAPublicKey`, which is the
499/// inner `SEQUENCE { INTEGER n, INTEGER e }` and nothing else, so publishing
500/// it as-is yields a record every verifier rejects.
501///
502/// ```text
503/// SubjectPublicKeyInfo ::= SEQUENCE {
504/// algorithm AlgorithmIdentifier, -- rsaEncryption, NULL
505/// subjectPublicKey BIT STRING -- the RSAPublicKey DER
506/// }
507/// ```
508pub fn rsa_spki_der(pkcs1: &[u8]) -> Vec<u8> {
509 // BIT STRING: tag, length, and a leading octet giving the number of
510 // unused bits in the final octet -- always zero for a whole-byte payload.
511 let mut bit_string = Vec::with_capacity(pkcs1.len() + 8);
512 bit_string.push(0x03);
513 der_write_len(&mut bit_string, pkcs1.len() + 1);
514 bit_string.push(0x00);
515 bit_string.extend_from_slice(pkcs1);
516
517 let body_len = RSA_ALG_ID_DER.len() + bit_string.len();
518 let mut out = Vec::with_capacity(body_len + 8);
519 out.push(0x30);
520 der_write_len(&mut out, body_len);
521 out.extend_from_slice(&RSA_ALG_ID_DER);
522 out.extend_from_slice(&bit_string);
523 out
524}
525
526/// Append a DER definite-form length.
527///
528/// Lengths below 128 are a single byte. Anything larger is the long form: a
529/// leading byte carrying the count of length octets with the high bit set,
530/// then the length itself, big-endian and minimally encoded. A 2048-bit key
531/// needs the long form, so getting this wrong is not a corner case.
532fn der_write_len(out: &mut Vec<u8>, len: usize) {
533 if len < 0x80 {
534 out.push(len as u8);
535 return;
536 }
537 let mut be = Vec::with_capacity(8);
538 let mut n = len;
539 while n > 0 {
540 be.push((n & 0xff) as u8);
541 n >>= 8;
542 }
543 be.reverse();
544 out.push(0x80 | (be.len() as u8));
545 out.extend_from_slice(&be);
546}
547
548
549// ┌───────────────────────────────────────────────────────────────────────────┐
550// │ TESTS │
551// └───────────────────────────────────────────────────────────────────────────┘
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 // A 2048-bit RSA key in PKCS#8 DER, generated once with `openssl genpkey
558 // -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -outform DER | base64 -w0`.
559 // A test key and nothing else: it signs nothing that exists.
560 const TEST_RSA_PKCS8_B64: &str = include_str!("../tests/data/dkim_rsa_test_key.b64");
561
562 fn rsa_signer() -> DkimSigner {
563 // The fixture is one unwrapped line, but a decoder that refuses
564 // whitespace should not be the thing that breaks if it is ever
565 // regenerated without `-w0`.
566 let stripped: String = TEST_RSA_PKCS8_B64.chars()
567 .filter(|c| !c.is_whitespace())
568 .collect();
569 let der = match base64::decode(&stripped) {
570 Ok(d) => d,
571 Err(e) => panic!("decoding the test key: {}", e),
572 };
573 match DkimSigner::from_pkcs8(&der, "example.com", "rsa1") {
574 Ok(s) => s,
575 Err(e) => panic!("loading the test key: {}", e),
576 }
577 }
578
579 fn message() -> Vec<u8> {
580 let m = "From: sender@example.com\r\n\
581 To: rcpt@elsewhere.example\r\n\
582 Subject: a test\r\n\
583 Date: Mon, 14 Jul 2026 12:00:00 +0000\r\n\
584 \r\n\
585 Hello.\r\n";
586 m.as_bytes().to_vec()
587 }
588
589 /// An RSA key must be recognised as one, and produce an rsa-sha256
590 /// signature rather than quietly signing with something else.
591 #[test]
592 fn test_an_rsa_key_signs_rsa_sha256_00() {
593 let s = rsa_signer();
594 assert_eq!(s.algorithm(), "rsa-sha256");
595 let signed = match s.sign(&message(), &[], 1_784_000_000) {
596 Ok(b) => b,
597 Err(e) => panic!("signing: {}", e),
598 };
599 let text = String::from_utf8_lossy(&signed);
600 assert!(text.starts_with("DKIM-Signature: "),
601 "the signature header must be prepended");
602 assert!(text.contains("a=rsa-sha256"),
603 "the a= tag must name the algorithm actually used:\n{}", text);
604 assert!(text.contains("s=rsa1") && text.contains("d=example.com"));
605 // The original message must survive untouched below the header.
606 assert!(text.contains("\r\nHello.\r\n"));
607 }
608
609 /// An ed25519 key must still sign ed25519 -- the algorithm follows the
610 /// key, and adding RSA must not have quietly changed the existing path.
611 #[test]
612 fn test_an_ed25519_key_still_signs_ed25519_00() {
613 let s = match DkimSigner::generate("example.com", "ed1") {
614 Ok(s) => s,
615 Err(e) => panic!("generate: {}", e),
616 };
617 assert_eq!(s.algorithm(), "ed25519-sha256");
618 let signed = match s.sign(&message(), &[], 1_784_000_000) {
619 Ok(b) => b,
620 Err(e) => panic!("signing: {}", e),
621 };
622 let text = String::from_utf8_lossy(&signed);
623 assert!(text.contains("a=ed25519-sha256"));
624 assert!(s.dns_txt_record().starts_with("v=DKIM1; k=ed25519; p="));
625 }
626
627 /// The published RSA record must be a SubjectPublicKeyInfo, because that
628 /// is what every verifier parses. Publishing ring's bare PKCS#1 key would
629 /// yield a record that looks fine and that nothing can read.
630 #[test]
631 fn test_the_rsa_record_publishes_a_subject_public_key_info_00() {
632 let s = rsa_signer();
633 let rec = s.dns_txt_record();
634 assert!(rec.starts_with("v=DKIM1; k=rsa; p="), "got: {}", rec);
635 let p = match rec.split("p=").nth(1) {
636 Some(p) => p,
637 None => panic!("no p= tag"),
638 };
639 let der = match base64::decode(p) {
640 Ok(d) => d,
641 Err(e) => panic!("p= is not base64: {}", e),
642 };
643 // A SubjectPublicKeyInfo is a SEQUENCE whose first element is the
644 // rsaEncryption AlgorithmIdentifier. A bare PKCS#1 RSAPublicKey would
645 // begin SEQUENCE, INTEGER (0x02) instead.
646 assert_eq!(der[0], 0x30, "SubjectPublicKeyInfo must be a SEQUENCE");
647 assert!(der.windows(RSA_ALG_ID_DER.len())
648 .any(|w| w == RSA_ALG_ID_DER),
649 "the rsaEncryption AlgorithmIdentifier is missing: this is a bare \
650 PKCS#1 key, which no verifier will read");
651 }
652
653 /// The signature must verify against an *independent* implementation,
654 /// using the public key exactly as Steel publishes it. A signer that is
655 /// merely self-consistent -- one whose own code agrees with itself -- can
656 /// still emit something every receiver on earth rejects, and DKIM fails
657 /// silently: the mail is simply treated as unsigned.
658 ///
659 /// So: sign here, then hand openssl the canonical input, the signature,
660 /// and the SubjectPublicKeyInfo from the DNS record, and make it agree.
661 #[test]
662 fn test_the_rsa_signature_verifies_under_openssl_00() {
663 use std::io::Write;
664 use std::process::Command;
665
666 let s = rsa_signer();
667 let msg = message();
668 let canon = match s.signing_input(&msg, &[], 1_784_000_000) {
669 Ok(c) => c,
670 Err(e) => panic!("canonicalising: {}", e),
671 };
672 let signed = match s.sign(&msg, &[], 1_784_000_000) {
673 Ok(b) => b,
674 Err(e) => panic!("signing: {}", e),
675 };
676
677 // Pull b= back out of the header we just wrote, unfolding it. The tag is
678 // written folded, and base64 decoding refuses whitespace, so the fold
679 // has to come out here rather than be tolerated there.
680 let text = String::from_utf8_lossy(&signed);
681 let b_tag = match text.split("b=").nth(1) {
682 Some(t) => t,
683 None => panic!("no b= tag in:\n{}", text),
684 };
685 let b64: String = b_tag.chars()
686 .take_while(|c| *c != '\r' && *c != '\n')
687 .filter(|c| !c.is_whitespace())
688 .collect();
689 let sig = match base64::decode(&b64) {
690 Ok(v) => v,
691 Err(e) => panic!("b= is not base64 ({}): {:?}", e, b64),
692 };
693
694 // The public key, exactly as it goes into DNS.
695 let rec = s.dns_txt_record();
696 let p = match rec.split("p=").nth(1) {
697 Some(p) => p,
698 None => panic!("no p="),
699 };
700 let spki = match base64::decode(p) {
701 Ok(d) => d,
702 Err(e) => panic!("p= is not base64: {}", e),
703 };
704
705 let dir = std::env::temp_dir().join("fe2o3_dkim_openssl_test");
706 let _ = std::fs::create_dir_all(&dir);
707 let write = |name: &str, bytes: &[u8]| -> std::path::PathBuf {
708 let path = dir.join(name);
709 match std::fs::File::create(&path)
710 .and_then(|mut f| f.write_all(bytes))
711 {
712 Ok(()) => (),
713 Err(e) => panic!("writing {}: {}", name, e),
714 }
715 path
716 };
717 let key_path = write("pub.der", &spki);
718 let sig_path = write("sig.bin", &sig);
719 let data_path = write("data.txt", canon.as_bytes());
720
721 let out = Command::new("openssl")
722 .arg("dgst").arg("-sha256")
723 .arg("-verify").arg(&key_path)
724 .arg("-keyform").arg("DER")
725 .arg("-signature").arg(&sig_path)
726 .arg(&data_path)
727 .output();
728 let out = match out {
729 Ok(o) => o,
730 Err(e) => panic!("openssl not runnable: {}", e),
731 };
732 let stdout = String::from_utf8_lossy(&out.stdout);
733 let stderr = String::from_utf8_lossy(&out.stderr);
734 assert!(stdout.contains("Verified OK"),
735 "openssl refused the signature.\nstdout: {}\nstderr: {}\n\
736 canonical input was:\n{:?}", stdout, stderr, canon);
737 let _ = std::fs::remove_dir_all(&dir);
738 }
739
740 /// Dual signing: an ed25519 signature and an RSA signature on the same
741 /// message must each stand on its own.
742 ///
743 /// The second signer runs over the output of the first, which already
744 /// carries a `DKIM-Signature` header. That is only safe because a
745 /// DKIM-Signature field is not among the covered headers and the body is
746 /// untouched -- so the first signature must still verify afterwards. If
747 /// the second pass disturbed it, both signatures would break and the mail
748 /// would be treated as unsigned by everyone.
749 #[test]
750 fn test_two_signatures_do_not_disturb_each_other_00() {
751 let ed = match DkimSigner::generate("example.com", "ed1") {
752 Ok(s) => s,
753 Err(e) => panic!("generate: {}", e),
754 };
755 let rsa = rsa_signer();
756 let msg = message();
757
758 // What the ed25519 signer signs, before anything else touches it.
759 let ed_input = match ed.signing_input(&msg, &[], 1_784_000_000) {
760 Ok(c) => c,
761 Err(e) => panic!("{}", e),
762 };
763
764 let once = match ed.sign(&msg, &[], 1_784_000_000) {
765 Ok(b) => b,
766 Err(e) => panic!("{}", e),
767 };
768 let twice = match rsa.sign(&once, &[], 1_784_000_000) {
769 Ok(b) => b,
770 Err(e) => panic!("{}", e),
771 };
772
773 let text = String::from_utf8_lossy(&twice);
774 assert_eq!(text.matches("DKIM-Signature:").count(), 2,
775 "both signatures must be present:\n{}", text);
776 assert!(text.contains("a=rsa-sha256") && text.contains("a=ed25519-sha256"),
777 "one of each algorithm:\n{}", text);
778
779 // The crux: what the ed25519 signer would sign over the *doubly*
780 // signed message is byte-for-byte what it signed originally. So its
781 // signature still verifies, despite the RSA header now sitting above
782 // it.
783 let ed_input_after = match ed.signing_input(&twice, &[], 1_784_000_000) {
784 Ok(c) => c,
785 Err(e) => panic!("{}", e),
786 };
787 assert_eq!(ed_input, ed_input_after,
788 "the second signature changed what the first one covers");
789 }
790
791 /// Every base64 string this signer puts on the wire must be character for
792 /// character what the `base64` crate would have written, because that is
793 /// what it did write until this module changed encoders, and a receiver
794 /// checks the string it was sent.
795 ///
796 /// The values are taken from a real signing run rather than a fixture:
797 /// `p=` from the DNS record, `bh=` and `b=` from the header. The external
798 /// crate decodes each one and re-encodes it, and its answer must be the
799 /// string that came out of here.
800 #[test]
801 fn test_the_wire_base64_matches_the_base64_crate_00() {
802 let s = rsa_signer();
803 let signed = match s.sign(&message(), &[], 1_784_000_000) {
804 Ok(b) => b,
805 Err(e) => panic!("signing: {}", e),
806 };
807 let text = String::from_utf8_lossy(&signed);
808
809 // One tag's value, unfolded, up to the separator that ends it.
810 let tag = |name: &str| -> String {
811 let after = match text.split(name).nth(1) {
812 Some(t) => t,
813 None => panic!("no {} tag in:\n{}", name, text),
814 };
815 after.chars()
816 .take_while(|c| *c != ';' && *c != '\r' && *c != '\n')
817 .filter(|c| !c.is_whitespace())
818 .collect()
819 };
820 let rec = s.dns_txt_record();
821 let p = match rec.split("p=").nth(1) {
822 Some(p) => p.to_string(),
823 None => panic!("no p= tag in: {}", rec),
824 };
825 let values = [
826 ("bh=", tag("bh=")),
827 ("b=", tag("b=")),
828 ("p=", p),
829 ];
830
831 for (name, encoded) in &values {
832 assert!(!encoded.is_empty(), "the {} tag is empty", name);
833 let theirs = match ::base64::decode(encoded) {
834 Ok(v) => v,
835 Err(e) => panic!("the base64 crate rejected our {} tag: {}", name, e),
836 };
837 assert_eq!(::base64::encode(&theirs), *encoded,
838 "the {} tag is not what the base64 crate writes for those bytes", name);
839 match base64::decode(encoded) {
840 Ok(ours) => assert_eq!(ours, theirs,
841 "the two decoders disagree about the {} tag", name),
842 Err(e) => panic!("our decoder rejected our own {} tag: {}", name, e),
843 }
844 }
845 }
846
847 // ── RFC 8463 Appendix A: the specification's own test vector ─────────
848 //
849 // The authoritative check, and the one whose absence let a broken signer
850 // ship. Everything else here tests Steel against Steel: that the crypto is
851 // sound, that the bytes round-trip, that the code agrees with itself. None
852 // of that catches signing the *wrong thing*, which is precisely what went
853 // wrong -- ed25519 was handed the canonical headers instead of their
854 // SHA-256 digest, producing a flawless signature over a message no
855 // verifier computes. Gmail said `dkim=fail` for three months and nobody
856 // asked it.
857
858 // Ed25519 private key seed from RFC 8463 §A.1.
859 const RFC8463_SEED_B64: &str = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=";
860 // The matching public key, RFC 8463 §A.2.
861 const RFC8463_PUB_B64: &str = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=";
862 // The signature the RFC says a correct signer produces, §A.3.
863 const RFC8463_SIG_B64: &str =
864 "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\
865 Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==";
866 // The body hash the RFC says relaxed body canonicalisation yields, §A.3.
867 const RFC8463_BH_B64: &str = "2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=";
868
869 /// The canonicalised header block for the RFC's ed25519 signature, built
870 /// by hand from RFC 6376 §3.4.2 relaxed rules and the vector's `h=` tag.
871 /// `h=` oversigns (from, subject and date appear twice); a second entry
872 /// for a name with no further instance contributes nothing.
873 fn rfc8463_signing_input() -> String {
874 let mut c = String::new();
875 c.push_str("from:Joe SixPack <joe@football.example.com>\r\n");
876 c.push_str("to:Suzie Q <suzie@shopping.example.net>\r\n");
877 c.push_str("subject:Is dinner ready?\r\n");
878 c.push_str("date:Fri, 11 Jul 2003 21:00:37 -0700 (PDT)\r\n");
879 c.push_str("message-id:<20030712040037.46341.5F8J@football.example.com>\r\n");
880 c.push_str("dkim-signature:v=1; a=ed25519-sha256; c=relaxed/relaxed; \
881 d=football.example.com; i=@football.example.com; q=dns/txt; \
882 s=brisbane; t=1528637909; h=from : to : subject : date : \
883 message-id : from : subject : date; \
884 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=");
885 c
886 }
887
888 /// The body the RFC signs, §A.3.
889 fn rfc8463_body() -> &'static [u8] {
890 b"Hi.\r\n\r\nWe lost the game. Are you hungry yet?\r\n\r\nJoe.\r\n"
891 }
892
893 // The message RFC 6376 §3.4.5 canonicalises as its worked example. Note the
894 // space before the colon in `B`, the tab-folded continuation, and the trailing
895 // empty lines -- every one of them is a rule under test.
896 const RFC6376_EXAMPLE: &[u8] = b"A: X\r\nB : Y\t\r\n\tZ \r\n\r\n C \r\nD \t E\r\n\r\n\r\n";
897
898 /// Relaxed *header* canonicalisation, driven through the real code path, must reproduce the
899 /// example output RFC 6376 §3.4.5 publishes.
900 ///
901 /// This is the gap the ed25519 fix left behind. `test_rfc8463_ed25519_signature_vector_00`
902 /// signs `rfc8463_signing_input()`, which is **hand-typed** -- it bypasses
903 /// `header_fields` and `relaxed_value` entirely, so the functions that canonicalise
904 /// every real message were pinned by nothing at all.
905 #[test]
906 fn test_rfc6376_relaxed_header_canonicalisation_00() {
907 let (raw_headers, _body) = split_headers_body(RFC6376_EXAMPLE);
908 let fields = match header_fields(raw_headers) {
909 Ok(f) => f,
910 Err(e) => panic!("the RFC 6376 example header would not read: {}", e),
911 };
912 let mut canon = String::new();
913 for (name, value) in fields {
914 canon.push_str(&relaxed_header(&name, &value));
915 }
916 assert_eq!(canon, "a:X\r\nb:Y Z\r\n",
917 "relaxed header canonicalisation disagrees with RFC 6376 §3.4.5");
918 }
919
920 /// Relaxed *body* canonicalisation must reproduce the same example's body output.
921 #[test]
922 fn test_rfc6376_relaxed_body_canonicalisation_00() {
923 let (_raw_headers, body) = split_headers_body(RFC6376_EXAMPLE);
924 assert_eq!(canonicalise_body_relaxed(body), b" C\r\nD E\r\n".to_vec(),
925 "relaxed body canonicalisation disagrees with RFC 6376 §3.4.5");
926 }
927
928 /// Relaxed body canonicalisation must produce the RFC's `bh=`.
929 #[test]
930 fn test_rfc8463_body_hash_00() {
931 let canon = canonicalise_body_relaxed(rfc8463_body());
932 let bh = base64::encode(sha(&SHA256, &canon).as_ref());
933 assert_eq!(bh, RFC8463_BH_B64,
934 "relaxed body canonicalisation disagrees with RFC 8463 A.3");
935 }
936
937 /// **The test that would have caught it.** Sign the RFC's own canonical
938 /// input with the RFC's own key, and produce the RFC's own signature.
939 ///
940 /// ed25519-sha256 signs the SHA-256 *digest* of the canonicalised headers
941 /// (RFC 8463 §3: "It signs the hash with the PureEdDSA variant Ed25519").
942 /// Ed25519 hashes its input again internally with SHA-512, so handing it
943 /// the canonical bytes signs a different message entirely -- valid, and
944 /// worthless.
945 #[test]
946 fn test_rfc8463_ed25519_signature_vector_00() {
947 let seed = match base64::decode(RFC8463_SEED_B64) {
948 Ok(v) => v,
949 Err(e) => panic!("seed: {}", e),
950 };
951 let pubkey = match base64::decode(RFC8463_PUB_B64) {
952 Ok(v) => v,
953 Err(e) => panic!("pubkey: {}", e),
954 };
955 let kp = match Ed25519KeyPair::from_seed_and_public_key(&seed, &pubkey) {
956 Ok(kp) => kp,
957 Err(e) => panic!("the RFC's own key pair was rejected: {}", e),
958 };
959 let signer = DkimSigner {
960 pkcs8: Vec::new(),
961 key: DkimKey::Ed25519(kp),
962 domain: "football.example.com".to_string(),
963 selector: "brisbane".to_string(),
964 };
965
966 let canon = rfc8463_signing_input();
967 let sig = match signer.sign_canonical(canon.as_bytes()) {
968 Ok(s) => s,
969 Err(e) => panic!("signing: {}", e),
970 };
971 let got = base64::encode(&sig);
972 let want: String = RFC8463_SIG_B64.chars()
973 .filter(|c| !c.is_whitespace())
974 .collect();
975 assert_eq!(got, want,
976 "Steel does not reproduce RFC 8463's signature over RFC 8463's \
977 own input. This is what a receiver checks, and it is the only \
978 check that matters.");
979 }
980
981 /// DER long-form lengths: a 2048-bit key needs them, so an off-by-one
982 /// here produces a record that is silently unparseable.
983 #[test]
984 fn test_der_lengths_00() {
985 let mut out = Vec::new();
986 der_write_len(&mut out, 0x7f);
987 assert_eq!(out, vec![0x7f], "short form up to 127");
988
989 out.clear();
990 der_write_len(&mut out, 0x80);
991 assert_eq!(out, vec![0x81, 0x80], "long form starts at 128");
992
993 out.clear();
994 der_write_len(&mut out, 270);
995 assert_eq!(out, vec![0x82, 0x01, 0x0e], "two length octets");
996 }
997}