Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions src/internet_identity/src/dkim/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
//! DKIM verifier (RFC 6376 + RFC 8463).
//! DKIM primitives (RFC 6376 + RFC 8463).
//!
//! Hand-rolled because the well-tested upstream alternative (Stalwart's
//! `mail-auth`) pulls a non-optional `hickory-resolver` dep that won't
//! compile to `wasm32-unknown-unknown`. Each sub-module corresponds to a
//! piece of RFC 6376 the verifier needs:
//! compile to `wasm32-unknown-unknown`. Top-level orchestration moved
//! to [`crate::email_recovery::typestate`] — the canister consumes a
//! `VerifiedSmtpRequest` from stage 3 of the typestate pipeline, which
//! calls into the primitives here directly.
//!
//! Sub-modules:
//!
//! - `parse` — `DKIM-Signature` header tag-list parser (§3.5).
//! - `dns_record` — DKIM TXT record parser (§3.6.2).
Expand All @@ -13,8 +17,9 @@
//! - `signature` — RSA-SHA256 (RFC 5702 / RFC 8301) and Ed25519-SHA256
//! (RFC 8463) signature verification, on top of the existing `rsa`
//! and `ed25519-dalek` workspace deps.
//! - `verify` — orchestration: multi-signature loop, tag enforcement,
//! accept-on-first-pass.
//! - `verify` — `build_header_hash_input` (§3.7) plus the `simple` body
//! canonicalisation helper; the multi-signature loop and tag
//! enforcement live in the typestate now.
//! - `tag_checks` — the **tag-contract facade** (two `enforce_*` umbrella
//! functions) that both pipelines route their tag enforcement
//! through. The facade is the single source of truth so the DoH and
Expand All @@ -38,15 +43,15 @@
//! that runs immediately after the umbrella gives the most useful
//! diagnostic.
//!
//! The verifier consumes a DKIM TXT record (sourced either from a
//! DNSSEC-verified `DnsProofBundle` cached at prepare time, or via
//! `crate::doh::fetch_txt` at email-arrival time) plus a parsed
//! `SmtpRequest`. It does not make any DNS calls itself; the caller
//! is responsible for delivering the trusted public-key bytes.
//! The DKIM TXT record is sourced either from a DNSSEC-verified
//! `DnsProofBundle` cached at prepare time, or via
//! `crate::doh::fetch_txt` at email-arrival time. This module does
//! not make any DNS calls itself; the caller is responsible for
//! delivering the trusted public-key bytes.

// `crate::email_recovery::smtp::verify_setup_email` is the in-canister
// consumer; some less-used items in the public surface aren't yet
// referenced. Suppress dead-code warnings until those land.
// `crate::email_recovery::typestate` is the in-canister consumer;
// some less-used items in the public surface aren't yet referenced.
// Suppress dead-code warnings until those land.
#![allow(dead_code)]

mod canonicalize;
Expand All @@ -55,20 +60,15 @@ mod parse;
mod signature;
mod tag_checks;
#[cfg(test)]
mod test_vectors;
pub(crate) mod test_vectors;
mod types;
mod verify;

#[allow(unused_imports)]
pub use types::{
Algorithm, BodyCanon, DkimCheck, DkimCheckName, DkimCheckStatus, DkimVerifyResult, HeaderCanon,
Algorithm, BodyCanon, DkimCheck, DkimCheckName, DkimCheckStatus, HeaderCanon,
VerificationFailReason,
};
// Re-exported as `verify_dkim` so downstream callers (the dmarc layer)
// don't have to deal with both a `dkim::verify` and `dmarc::verify`
// in scope at the same time.
#[allow(unused_imports)]
pub use verify::verify as verify_dkim;

// Building blocks consumed by the email-recovery two-phase pipeline
// (`crate::email_recovery::smtp` parses the signature and computes
Expand Down
153 changes: 85 additions & 68 deletions src/internet_identity/src/dkim/test_vectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,19 @@
//! signed `.eml` files and the matching DKIM TXT record (containing the
//! public key) live in `test_vectors/dkim/`. See that directory's
//! README for the regeneration procedure.
//!
//! Each test drives the email-recovery typestate end-to-end: stage 1
//! (RFC 5322 §3.6 well-formedness) → stage 2 (parse every
//! `DKIM-Signature`) → stage 3 (cryptographic check + DMARC alignment).
//! Failures surface via `VerificationError.last_reason`, which carries
//! the same `VerificationFailReason` values the per-signature inner
//! loop produces.

use super::types::{DkimVerifyResult, VerificationFailReason};
use super::verify::verify;
use super::types::VerificationFailReason;
use crate::email_recovery::typestate::{
SignedSmtpRequestProjection, UnverifiedSmtpRequest, VerificationContext, VerificationError,
VerifiedSmtpRequest,
};
use internet_identity_interface::internet_identity::types::smtp::{
SmtpAddress, SmtpEnvelope, SmtpHeader, SmtpMessage, SmtpRequest,
};
Expand All @@ -34,7 +44,7 @@ const SYNTH_RSA_TXT: &str =
/// produces. Continuation lines (those starting with WSP) are unfolded
/// into the previous header's value, with the leading WSP preserved per
/// RFC 5322 §2.2.3 so DKIM relaxed canonicalisation can collapse it.
fn parse_eml(raw: &[u8]) -> SmtpRequest {
pub(crate) fn parse_eml(raw: &[u8]) -> SmtpRequest {
// Find the first \r\n\r\n that separates headers from body.
let mut header_end = 0;
while header_end + 4 <= raw.len() {
Expand Down Expand Up @@ -172,68 +182,71 @@ fn frozen_now() -> u64 {
1_777_972_289 // matches t= in the committed fixtures
}

/// Run an `SmtpRequest` through the full typestate pipeline against
/// the given DKIM/DMARC inputs. Stage-1 / stage-2 failures panic
/// (the fixtures must satisfy them — they're real signed `.eml`
/// files); only stage-3 results are returned.
fn run(
req: SmtpRequest,
dkim_txt: &str,
dmarc_txt: Option<&str>,
now: u64,
) -> Result<VerifiedSmtpRequest, VerificationError> {
let unverified = UnverifiedSmtpRequest::try_from(req)
.expect("fixture must satisfy stage 1 (bounds + RFC 5322 §3.6)");
let projections: Vec<SignedSmtpRequestProjection> = unverified
.try_into()
.expect("fixture must parse at least one DKIM-Signature");
let ctx = VerificationContext {
dkim_txt,
dmarc_txt,
now_secs: now,
};
VerifiedSmtpRequest::try_from((projections, &ctx))
}

#[test]
fn verifies_synthetic_rsa_relaxed_relaxed() {
let req = parse_eml(SYNTH_RSA_RELAXED_RELAXED);
let result = verify(&req, SYNTH_RSA_TXT, frozen_now());
match result {
DkimVerifyResult::Verified { dkim_domain, .. } => {
assert_eq!(dkim_domain, "test.example.com");
}
other => panic!("expected Verified, got {:?}", other),
}
let verified = run(req, SYNTH_RSA_TXT, None, frozen_now())
.expect("synth-rsa-relaxed-relaxed.eml must verify");
assert_eq!(verified.winning_dkim_domain, "test.example.com");
}

#[test]
fn verifies_synthetic_rsa_relaxed_simple_body() {
let req = parse_eml(SYNTH_RSA_RELAXED_SIMPLE);
let result = verify(&req, SYNTH_RSA_TXT, frozen_now());
match result {
DkimVerifyResult::Verified { dkim_domain, .. } => {
assert_eq!(dkim_domain, "test.example.com");
}
other => panic!("expected Verified, got {:?}", other),
}
let verified = run(req, SYNTH_RSA_TXT, None, frozen_now())
.expect("synth-rsa-relaxed-simple.eml must verify");
assert_eq!(verified.winning_dkim_domain, "test.example.com");
}

#[test]
fn rejects_simple_simple_canonicalization() {
let req = parse_eml(SYNTH_RSA_SIMPLE_SIMPLE);
let result = verify(&req, SYNTH_RSA_TXT, frozen_now());
match result {
DkimVerifyResult::Unverified { reason, .. } => {
assert_eq!(reason, VerificationFailReason::UnsupportedCanonicalization);
}
other => panic!(
"expected Unverified(UnsupportedCanonicalization), got {:?}",
other
),
}
let err = run(req, SYNTH_RSA_TXT, None, frozen_now()).unwrap_err();
assert_eq!(
err.last_reason,
VerificationFailReason::UnsupportedCanonicalization,
);
}

#[test]
fn rejects_flipped_body_byte() {
let req = parse_eml(SYNTH_RSA_RELAXED_RELAXED);
let mut req = req;
let mut req = parse_eml(SYNTH_RSA_RELAXED_RELAXED);
let message = req.message.as_mut().unwrap();
let mut body = message.body.to_vec();
if !body.is_empty() {
body[0] ^= 0x01;
}
message.body = ByteBuf::from(body);
let result = verify(&req, SYNTH_RSA_TXT, frozen_now());
match result {
DkimVerifyResult::Unverified { reason, .. } => {
assert_eq!(reason, VerificationFailReason::BodyHashMismatch);
}
other => panic!("expected BodyHashMismatch, got {:?}", other),
}
let err = run(req, SYNTH_RSA_TXT, None, frozen_now()).unwrap_err();
assert_eq!(err.last_reason, VerificationFailReason::BodyHashMismatch);
}

#[test]
fn rejects_flipped_signature_byte() {
let req = parse_eml(SYNTH_RSA_RELAXED_RELAXED);
let mut req = req;
let mut req = parse_eml(SYNTH_RSA_RELAXED_RELAXED);
let message = req.message.as_mut().unwrap();
// Flip a byte inside the b= value of the DKIM-Signature header.
for header in message.headers.iter_mut() {
Expand All @@ -253,19 +266,16 @@ fn rejects_flipped_signature_byte() {
header.value = String::from_utf8(bytes).unwrap();
}
}
let result = verify(&req, SYNTH_RSA_TXT, frozen_now());
let err = run(req, SYNTH_RSA_TXT, None, frozen_now()).unwrap_err();
assert!(
matches!(
result,
DkimVerifyResult::Unverified {
reason: VerificationFailReason::SignatureInvalid
| VerificationFailReason::SignatureMalformed(_)
| VerificationFailReason::BodyHashMismatch,
..
}
err.last_reason,
VerificationFailReason::SignatureInvalid
| VerificationFailReason::SignatureMalformed(_)
| VerificationFailReason::BodyHashMismatch,
),
"expected Unverified with signature/body failure, got {:?}",
result
"expected signature/body failure, got {:?}",
err.last_reason
);
}

Expand All @@ -275,38 +285,45 @@ fn rejects_wrong_public_key() {
// A valid-shaped DKIM TXT record but with a different key
// (truncated to a structural-but-not-correct value).
let bad_txt = "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIDAQAB";
let result = verify(&req, bad_txt, frozen_now());
let err = run(req, bad_txt, None, frozen_now()).unwrap_err();
assert!(
matches!(
result,
DkimVerifyResult::Unverified {
reason: VerificationFailReason::SignatureInvalid
| VerificationFailReason::DnsRecordMalformed(_),
..
}
err.last_reason,
VerificationFailReason::SignatureInvalid
| VerificationFailReason::DnsRecordMalformed(_),
),
"expected Unverified, got {:?}",
result
"expected signature/DNS failure, got {:?}",
err.last_reason
);
}

#[test]
fn no_dkim_signature_header() {
let req = parse_eml(SYNTH_RSA_RELAXED_RELAXED);
let mut req = req;
// Strip the DKIM-Signature header.
fn rejects_missing_dkim_signature_header() {
// The DNS layer in production never delivers a message without a
// DKIM-Signature; we exercise the negative path by stripping it.
// Under the typestate the rejection moves up to stage 1 — the
// RFC 5322 §3.6 well-formedness pass enforces ≥1 DKIM-Signature
// when a message is present. The visible verdict is therefore an
// `RfcError::HeaderCount` rather than a `VerificationFailReason`
// — but the meaning is the same: "we can't reason about this
// message because it has no signature."
use crate::email_recovery::typestate::{HeaderCount, RfcError};
let mut req = parse_eml(SYNTH_RSA_RELAXED_RELAXED);
let message = req.message.as_mut().unwrap();
message
.headers
.retain(|h| !h.name.eq_ignore_ascii_case("DKIM-Signature"));
let result = verify(&req, SYNTH_RSA_TXT, frozen_now());
assert!(matches!(
result,
DkimVerifyResult::Unverified {
reason: VerificationFailReason::NoSignature,
..
let err = UnverifiedSmtpRequest::try_from(req).unwrap_err();
match err {
RfcError::HeaderCount {
header: "DKIM-Signature",
found: 0,
expected,
} => {
assert_eq!(expected, HeaderCount::AT_LEAST_ONE);
}
));
other => panic!("expected missing DKIM-Signature, got {other:?}"),
}
}

#[test]
Expand Down
36 changes: 0 additions & 36 deletions src/internet_identity/src/dkim/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,39 +134,3 @@ pub enum VerificationFailReason {
/// exists, doesn't equal it exactly).
DmarcMisaligned,
}

/// DKIM-only verdict, the result of [`super::verify::verify`].
///
/// Per RFC 6376 §5.5 / design §5.5, an email may carry multiple
/// `DKIM-Signature` headers (e.g. original sender + mailing list
/// forwarder). The DKIM verifier accepts the email as soon as *any
/// one* signature passes the cryptographic check.
///
/// The combined DKIM + DMARC verdict (what callers actually want) is
/// [`crate::dmarc::EmailVerificationStatus`], returned by
/// `dmarc::verify_email`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DkimVerifyResult {
/// At least one signature passed.
Verified {
/// The `d=` of the signature that verified.
dkim_domain: String,
/// Per-step checks for the winning signature.
checks: Vec<DkimCheck>,
},
/// No signature passed.
Unverified {
/// Best-fit reason from the most-recently-attempted signature.
reason: VerificationFailReason,
/// Per-step checks across every signature attempted (each
/// signature contributes one block of checks; multi-signature
/// emails produce a flat concatenation).
checks: Vec<DkimCheck>,
},
}

impl DkimVerifyResult {
pub fn is_verified(&self) -> bool {
matches!(self, DkimVerifyResult::Verified { .. })
}
}
Loading
Loading