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 | |
| 32 | use crate::email::header::{ |
| 33 | header_fields, |
| 34 | split_headers_body, |
| 35 | }; |
| 36 | |
| 37 | use oxedyne_fe2o3_core::prelude::*; |
| 38 | use oxedyne_fe2o3_text::base64; |
| 39 | |
| 40 | use 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. |
| 75 | pub 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 | |
| 80 | impl 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. |
| 101 | pub 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. |
| 118 | pub struct DkimSigner { |
| 119 | pkcs8: Vec<u8>, |
| 120 | key: DkimKey, |
| 121 | domain: String, |
| 122 | selector: String, |
| 123 | } |
| 124 | |
| 125 | impl 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 | |
| 136 | impl 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. |
| 404 | fn 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`. |
| 447 | fn 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. |
| 453 | fn 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 }`. |
| 487 | const 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 | /// ``` |
| 508 | pub 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. |
| 532 | fn 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)] |
| 554 | mod 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 | } |