Skip to content

feat(dkim): hand-rolled RFC 6376 verifier (conflict-resolved branch for PR #3839)#3876

Closed
aterga wants to merge 22 commits into
mainfrom
claude/resolve-pr-conflicts-VwdCZ
Closed

feat(dkim): hand-rolled RFC 6376 verifier (conflict-resolved branch for PR #3839)#3876
aterga wants to merge 22 commits into
mainfrom
claude/resolve-pr-conflicts-VwdCZ

Conversation

@aterga
Copy link
Copy Markdown
Collaborator

@aterga aterga commented May 12, 2026

Resolves merge conflicts in #3839 against current main (which now contains PR #3838's squash-merge as 70e92cc).

Conflict summary

Only one textual conflict to resolve: src/internet_identity/src/main.rs. PR #3839 inserts mod dkim; next to mod dnssec; (the dnssec module was added by PR #3838 and is already on main). Resolved by keeping both module declarations.

The other touched files (Cargo.lock, src/internet_identity_interface/src/internet_identity/types.rs) auto-merged cleanly — Cargo.lock picked up minor-version bumps to indexmap/hashbrown/rustls-pki-types from main, and types.rs cleanly added the new smtp module re-exports.

Verification

  • cargo check -p internet_identity --bin internet_identity — clean.
  • cargo test -p internet_identity --bin internet_identity dkim — 78 DKIM tests pass.
  • cargo test -p internet_identity --bin internet_identity — all 326 canister tests pass.
  • cargo test -p internet_identity_interface --lib — all 57 interface tests pass (including new SMTP type tests).

How to use

This branch is intended for the external contributor (#3839 is from a fork) to pick up the conflict resolution. Either:

Design context: #3836 (email recovery design doc).


Generated by Claude Code

sea-snake and others added 22 commits May 4, 2026 14:57
…iring

First PR in the email-recovery stack (docs/ongoing/email-recovery.md §10
Phase 0). Lands the structural pieces of the DNSSEC verifier so PR #2
(DKIM verifier) and PR #4-#9 (storage + recovery methods) can build
against the types. Cryptographic verification logic and real DoH-captured
test vectors arrive in PR #1b.

What's in this PR:

- New workspace crate internet_identity_email_test_vectors with a
  placeholder loader and a README explaining what arrives in PR #1b
  (DNSSEC chains, DKIM happy-path + tampering vectors, DMARC alignment
  matrix).
- New DnssecConfig and DnssecRootAnchor types in
  internet_identity_interface, exposed at the top of InternetIdentityInit
  as 'dnssec_config'. Not specific to email recovery — any feature that
  verifies DNS records against the IANA-rooted DNSSEC chain consumes the
  same anchors.
- New dnssec/ module under src/internet_identity/src/ with type
  definitions (DnsProofBundle, SignedRRset, DelegationLink, Rrsig,
  DnsName, DnssecError, VerifiedRecord) and a stub 'verify' that returns
  Err(DnssecError::NotImplemented). Step-by-step TODOs reference §7.3 of
  the design doc.
- Trust-anchor list plumbed through init/post_upgrade into
  PersistentState.dnssec_config (and through StorablePersistentState for
  cross-upgrade persistence).
- Two unit tests for the stub verifier (NoTrustAnchors path,
  NotImplemented path) — flip to positive assertions in PR #1b.
- internet_identity.did updated with DnssecConfig / DnssecRootAnchor and
  the new init field.

What's deferred to PR #1b:

- RRSIG / DS / DNSKEY canonicalization and signature verification per
  RFC 4034 §6.
- Crypto deps for ECDSA-P256-SHA256 (alg 13) and Ed25519 (alg 15). RSA-
  SHA256 (alg 8) deps already in the workspace.
- Real DoH-captured DNSSEC chains for gmail.com, icloud.com,
  outlook.com, fastmail.com, proton.me, plus deliberately-tampered
  negatives.

Build: cargo check --target wasm32-unknown-unknown clean; 227 internet_identity
bin tests pass; 42 internet_identity_interface unit tests pass.
…scripts

The Dockerfile pre-builds workspace dependencies by COPY-ing each crate's
Cargo.toml and stubbing its lib.rs. Adding internet_identity_email_test_vectors
to Cargo.toml's workspace members without updating these hardcoded lists
causes 'docker-build-base' to fail at the cargo manifest discovery step.

Also add the new crate to the BACKEND_PATHS lists in
.github/actions/release/run.sh and scripts/make-upgrade-proposal so it
is included in release tarballs alongside canister_tests (also test-only).
- Drop the internet_identity_email_test_vectors crate. Test vectors will
  live as plain files at the repo root in PR 1b (still TBD which path);
  no separate crate needed for what is just data-on-disk shared between
  internet_identity unit tests and canister_tests integration tests via
  include_bytes! / fs::read.
- Revert the Dockerfile + release-script entries that registered the
  now-removed crate.
- Switch InternetIdentityInit.dnssec_config from Option<DnssecConfig>
  to Option<Option<DnssecConfig>> to match the same set/clear pattern
  as analytics_config and dummy_auth (per Copilot review). Outer None
  keeps the previously stored value; Some(None) clears; Some(Some(c))
  sets. Avoids a future breaking Candid change if operators ever need
  to detach trust anchors. Updates the consumer in apply_install_arg
  and the round-trip in config(), plus the .did declaration.
- Narrow the dnssec/mod.rs allow from `#![allow(dead_code,
  unused_imports)]` to just `#![allow(dead_code)]`, with per-item
  `#[allow(unused_imports)]` on the two re-exports that need it.
  This keeps unused_imports active so a real issue isn't masked when
  the verifier implementation lands in PR 1b.
Replaces the stub verify() with a working four-step DNSSEC validator
per docs/ongoing/email-recovery.md §7.3:

  1. Validate the bundle's root DNSKEY RRset against a configured trust
     anchor (matches digest of one DNSKEY KSK, then verifies the RRSIG
     covering the entire root DNSKEY RRset under that key).
  2. Walk the delegation chain top-down: each link's DS RRset verifies
     under the parent's DNSKEY, the child's DNSKEY RRset is self-signed
     by a KSK whose digest matches one of the parent's DS records.
  3. Verify the leaf RRset's RRSIG under the deepest zone's DNSKEY.
  4. Freshness check: every RRSIG's [inception, expiration] window must
     contain now ± 60s.

Algorithm coverage (RFC 8624 MUST):
  * 8 — RSA-SHA256, RFC 5702 (root, com, most legacy zones)
  * 13 — ECDSA-P256-SHA256, RFC 6605 (most TLDs, Cloudflare)
  * 15 — Ed25519, RFC 8080
Anything else is rejected with UnsupportedAlgorithm.

New deps: domain (NLnet Labs primitives — currently used for the
docstring/RFC reference frame; signature verification is hand-rolled
on RustCrypto), p256, ed25519-dalek. All wasm32-compatible.

New files:
  - src/internet_identity/src/dnssec/canonical.rs — owner-name
    canonicalization, RR canonical form, RRSIG signed-data
    construction (RFC 4034 §3.1.8.1, §6.2, §6.3), DS digest input.
  - src/internet_identity/src/dnssec/signature.rs — algorithm
    dispatch + DS digest matching.
  - src/internet_identity/src/dnssec/test_vectors.rs — cfg(test)
    JSON loader.
  - test_vectors/dnssec/cloudflare-com-2026-05.json — captured DoH
    chain for cloudflare.com TXT (root → com → cloudflare.com).
    Exercises both alg 8 and alg 13.
  - test_vectors/dnssec/iana-root-anchors-2026-05.json — IANA root
    KSK trust anchors (Klajeyz/2017 + Kmyv6jo/2024).
  - scripts/capture-dnssec-chain.py — reproducible DoH capture script.

Modified:
  - dnssec/types.rs: drop NotImplemented variant (now obsolete).
  - dnssec/verify.rs: replace stub with the real implementation; add
    7 unit tests covering happy path + 6 negative cases.
  - Cargo.toml + internet_identity/Cargo.toml: new deps.

Tests: 238 internet_identity bin tests pass (227 prior + 11 new dnssec
tests). Wasm32 build clean; full workspace cargo check clean (modulo the
unrelated frontend dist/ requirement).

Note on test data lifetime: the captured RRSIGs' validity windows expire
~2-4 weeks after capture. The tests use a 'frozen now' read from the
capture's _meta.captured_at_unix so freshness checks remain stable.
Re-run scripts/capture-dnssec-chain.py to refresh the captures when the
expiration approaches.
The clippy job flagged `#![cfg(test)]` on test_vectors.rs as a
duplicated attribute (the module declaration in dnssec/mod.rs already
gates it with `#[cfg(test)]`). Removed the redundant inner gate.

Also applied rustfmt to the four new dnssec module files so cargo-fmt
stays green.
- Drop unused domain workspace dep. The dep was added during planning when
  we considered using NLnet Labs' domain crate for canonicalization, but
  the verifier ended up hand-rolling everything (canonicalize_name,
  rrsig_rdata_for_signing, rr_canonical, ds_digest_input) on top of
  rsa/sha2/p256/ed25519-dalek alone. Removing it saves a 66-line
  Cargo.lock entry and shrinks dependency surface to no actual cost.
- scripts/capture-dnssec-chain.py docstring example: rename name/rdata/
  signature to name_hex/rdata_hex/signature_hex to match what the script
  actually emits (and what the canister-side test loader expects).
- scripts/capture-dnssec-chain.py --resolver help: was 'cloudflare-dns.com'
  but the actual default is https://1.1.1.1/dns-query. Make the help
  text match the default.
- Replace magic numbers across the dnssec module with named
  constants, co-located inside their using function when single-use
  and hoisted to wire.rs only when shared across modules. Every
  RDATA field offset and length now traces back to a named RFC
  reference.

- Beef up RFC citations on every public item: doc comments reference
  specific RFC 4033 / 4034 / 4035 / 5702 / 6605 / 8080 / 8624 sections
  rather than handwaving at "DNSSEC". Algorithm numbers in narrative
  text use the algorithm name (RSA-SHA256, ECDSA-P256-SHA256,
  Ed25519) instead of the bare IANA number.

- Add real-data test coverage for the four email-recovery target
  zones and the Ed25519 algorithm:
    * proton.me      — RSA-SHA256 end-to-end
    * protonmail.com — ECDSA-P256-SHA256 leaf
    * tutanota.com   — ECDSA-P256-SHA256 leaf
    * ed25519.nl     — Ed25519 leaf (closes the alg-15 real-data gap)

  Brings real-data algorithm coverage to the full RFC 8624 MUST set.
  Tests count: 243 (was 238).

- Remove scripts/capture-dnssec-chain.py — the captures committed in
  this change cover every algorithm and every email-recovery target
  the design doc names, so the script's job is done. Re-capture on
  root KSK rollover will be a one-off ad-hoc effort if it ever
  becomes necessary.
Three small fixes that lived on feat/doh-fallback as part of a
combined "Copilot review" commit (516c48d) properly belong in
PR 1 since they all concern the dnssec scaffold:

- verify.rs: handle the multi-anchor case correctly. During a KSK
  rollover the operator configures both the rolling-out and
  rolling-in KSKs (RFC 5011 §2). The old "return on first digest
  match" strategy could short-circuit onto the inactive anchor and
  never try the active one. Track digest-match state, try every
  candidate, and surface the cryptographic failure (not the generic
  RootAnchorMismatch) when at least one anchor matched but no
  signature verified.

- mod.rs: doc-comment correctness. The verifier itself makes no
  outcalls, but the email-recovery stack uses DoH for unsigned
  domains (PR 4). The previous wording overclaimed.

- iana-root-anchors-2026-05.json: corrected stale "_comment" that
  said the historical 19036 KSK was included. It isn't, and
  shouldn't be — production deployments should configure 20326 +
  38696 only.
Three improvements that lived as commits on later branches in the
email-recovery stack are scaffold-level — they belong in PR 1 with
the rest of the verifier, not buried in feature PRs that happen to
need them. Back-port them here so PR 1 ships a complete scaffold
that downstream feature PRs can build on without extending the
verifier surface in passing.

- Two-phase API split (originally on feat/email-recovery-storage-and-smtp
  as cfcc99e): expose `verify_root_dnskey_with_clock`,
  `verify_chain_with_clock`, `verify_extra_chains_with_clock`,
  and `verify_hops_with_clock` as standalone entry points alongside
  the top-level `verify_bundle_with_clock`. Two-phase callers
  (prepare → submit-leaf) can now cache a validated `ZoneKeysMap`
  across calls and only validate the new chains a follow-up
  submission crossed into, avoiding 3-5 RSA verifies per call.

- Multi-zone + CNAME-aware bundle shape (originally feat/email-recovery-flow
  as 43a466e): real DKIM resolution often crosses zone boundaries via
  CNAME (proton.me → proton.ch, tutanota.com → tutanota.de, M365 custom
  domains). Replace the old single-chain `DnsProofBundle { root_dnskey,
  chain, leaf }` with `DnsProofBundle { root_dnskey, chains, hops }`
  where each chain pins a signing zone in the new `ZoneKeysMap` and
  each hop is verified under whichever zone its `RRSIG.signer_name`
  identifies. Adds CNAME-chain coherence checks (first owner matches
  requested name, intermediates are CNAMEs whose target equals the
  next owner, no loops, ≤ MAX_CNAME_HOPS = 4) and four new error
  variants (DuplicateZone, UnknownSigningZone, HopOwnerOutsideZone,
  BadCnameChain, TooManyHops).

- DNSKEY-RRset RRSIG fix (originally feat/email-recovery-flow as 6788020):
  loosen step 2c of `verify_link` to verify the child DNSKEY rrset
  against any key in itself, not just the DS-pinned KSK. Real-world
  zones (proton.me, proton.ch, …) publish a DNSKEY rrset signed by
  both the KSK and the ZSK, and resolvers return whichever RRSIG
  comes first; resolving this on the FE side isn't generally possible.
  Step 2b still pins the DS-referenced KSK as part of the rrset, so
  the chain of trust is intact regardless of which RRSIG we verify.

Test-vector JSONs migrated to the new `{root_dnskey, chains, hops}`
shape. Five new tests cover the new failure modes (duplicate zone,
too many hops, hop owner mismatch, wrong requested type, subdomain
boundary). All five real-data chains still verify end-to-end.

Counts: 248 tests pass (was 243); cargo clippy clean with -D warnings;
wasm32 build clean.
Two-phase consumers (prepare → submit-leaf in the email-recovery
flow, landing in PR 5+6) need to inspect the validated zones after
the chain walk — typically to extract the single zone DNSKEY when
the skeleton bundle is single-zone, or to enumerate the map when
caching it across calls. Adding a read-only iterator is the
minimum surface change to support both.

Insertion order matches the order delegation chains were verified
in, which is the order callers supplied them.
Minor-version bumps inside the 0.9 line — semver-compatible, no
API changes. Picks up the defensive validations added in 0.9.7
(`RsaPrivateKey::from_components` always validates keys, PKCS#1 v1.5
no longer panics on tiny keys) and 0.9.8.

Does not address RUSTSEC-2023-0071 (Marvin Attack timing
sidechannel) — there's still no patched version on the 0.9.x line
and 0.10.0 is in -rc. Note that the Marvin Attack is a private-key
recovery attack on RSA decryption/signing; this canister only does
`RsaPublicKey::verify(...)` of caller-supplied bytes, so the
threat doesn't apply. We can document that with a `cargo audit`
ignore-with-justification when we add the audit step.

ed25519-dalek 2.2.0 and p256 0.13.2 are already on their latest
0.x.y stable.
Carries forward the wire-format Candid surface the off-chain SMTP gateway
already targets in the PoC PR, slimmed to just what PR 2's DKIM verifier
and PR 8's smtp_request dispatch will need:

- SmtpRequest / SmtpResponse / SmtpHeader / SmtpMessage / SmtpAddress /
  SmtpEnvelope, plus the size bounds and SMTP error codes.
- Validation helpers: validate_envelope, validate_message, validate_smtp_request.
- format_address (lowercases both halves so envelope casing can't bypass
  per-anchor lookups) and truncate_at_char_boundary (fallback to previous
  UTF-8 boundary, avoiding the multi-byte panic the PoC review flagged).

Drops the postbox-specific bits (PostboxEmail, ValidatedSmtpRequest, the
to.user → anchor parser) — they don't fit the email-recovery design, see
docs/ongoing/email-recovery.md §2 non-goals.

10 unit tests for bounds, address normalisation, and char-boundary
truncation. Wasm32 build unaffected.
First piece of the hand-rolled DKIM verifier. Going hand-rolled (rather
than depending on stalwartlabs/mail-auth) because mail-auth pulls a
non-optional hickory-resolver dep that fails to compile for
wasm32-unknown-unknown — we'd need to fork+patch with perpetual rebase
burden to use it.

This commit lands:

- src/internet_identity/src/dkim/types.rs: Algorithm (RsaSha256,
  Ed25519Sha256), HeaderCanon / BodyCanon (Relaxed, Simple), DkimCheck /
  DkimCheckName / DkimCheckStatus per-step diagnostics, and the public
  EmailVerificationStatus / VerificationFailReason result shape.
- src/internet_identity/src/dkim/parse.rs: structural tag-list parser
  for the DKIM-Signature header value. Splits on ';' first, then on the
  first '=' per element, so a literal 'b=' substring inside another
  tag's base64 doesn't get misread as the start of a new tag — that
  was the concrete bug the PoC PR review flagged. Folded whitespace
  inside base64 values is stripped before decoding. Tag names are
  case-insensitive; duplicate tags are rejected.
- mod.rs scaffolding wired into main.rs. dnssec module remains in place.

14 unit tests cover: minimal required tags, case-insensitive tag names,
folded whitespace, the b=-inside-bh= antipattern, v != 1, duplicate
tags, unsupported algorithms (rsa-sha1), ed25519-sha256, l/t/x tags,
explicit i= override, default canon, empty h=, missing required tags,
malformed base64.

Wasm32 build clean; full II suite still passes.
Implements the relaxed canonicalisation algorithms — the only ones our
DKIM verifier accepts on the header side (the parsed-pair gateway
contract precludes byte-exact 'simple/*' header canonicalization, see
design doc §5.2).

Header (§3.4.2): lowercase the name; unfold continuation lines (CRLF +
WSP -> WSP); collapse runs of WSP within the value to a single SP;
strip trailing WSP from the value; strip WSP around the colon. Single-
pass implementation that handles all five steps in one walk.

Body (§3.4.4): per-line WSP cleanup (collapse runs, strip trailing);
drop empty lines from the end of the body; ensure non-empty output
ends in exactly one CRLF; empty input maps to empty output (no
synthetic trailing CRLF).

18 unit tests cover both algorithms, including the edge cases that
trip up naïve implementations: empty bodies, bodies with only
whitespace, bodies without a trailing CRLF, internal empty lines that
must NOT be stripped, header values that are entirely whitespace, and
folded continuations with both space and tab continuations.
Parses the published DKIM key record into a DkimDnsRecord:
- key_type: rsa (default) or ed25519
- public_key: base64-decoded p= value (empty = revoked)
- testing: t=y flag
- strict_auid: t=s flag

Tag handling per §3.6.2.1 / §3.6.2.2:
- v=DKIM1 if present must be first; absent is acceptable.
- Tag names are case-insensitive (the PoC PR review specifically flagged
  P= versus p= mismatch as a real-world bug).
- Whitespace inside p= is tolerated — DNS TXT records can be split
  across multiple <character-string>s and may carry stray WSP at chunk
  boundaries.
- Duplicate tags are rejected (defence-in-depth even though the RFC
  technically allows shadowing).
- t= flag list is colon-separated; we honour 'y' and 's', ignore others.
- Unknown tags are silently dropped per RFC.

16 unit tests cover defaulting, both key types, both t= flags
individually and combined, malformed inputs (missing p, v not first,
unsupported v/k), whitespace inside p, empty p, and key-type/algorithm
matching.

Wasm32 build remains clean.
Both algorithms RFC 8624 specifies as MUST for DKIM (RFC 8301 + RFC 8463),
no others. Reuses rsa, sha2, and ed25519-dalek that are already in the
workspace from PR 1.

RSA path (RFC 5702 / RFC 6376 §3.3.1):
- DKIM publishes RSA keys in SubjectPublicKeyInfo (DER), so we use
  RustCrypto's DecodePublicKey::from_public_key_der which handles the
  X.509 + PKCS#1 wrapping.
- Enforces RSA_MIN_KEY_BITS = 1024 per design §5.6 (planned lift to
  2048 once telemetry confirms zero impact).
- Sanity-checks signature length == modulus length before invoking
  RSASSA-PKCS1-v1_5 verify, so a structural mismatch surfaces as a
  clear MalformedSignature instead of an opaque crypto error.

Ed25519 path (RFC 8463):
- Public key is 32 raw bytes (no SPKI wrapping), signature is 64 bytes.
- Wraps the input in SHA-256 ourselves before calling ed25519-dalek's
  pure-Ed25519 verify, since RFC 8463 specifies signing the SHA-256 of
  the canonical header hash input rather than the input directly.

Plus body_hash_sha256 (the bh= side): SHA-256 of the canonical body,
optionally truncated to l= bytes per RFC 6376 §3.4.5. Truncation caps
at the body length so an l= larger than the body just uses the whole
thing.

7 unit tests cover algorithm/key-type mismatches, malformed RSA keys
(non-SPKI bytes), wrong-length Ed25519 keys / signatures, and the body
hash with and without l=. Wasm32 build remains clean.
Ties parse + canonicalize + dns_record + signature into the public
`verify(email, dkim_txt, now_secs)` API. Implements:

- Multi-signature loop per RFC 6376 §5.5: try every DKIM-Signature
  header in order, accept on first pass, return Unverified with the
  most recent reason if all fail.
- Tag enforcement per design §5.4:
  - c=simple/* on header side rejected with UnsupportedCanonicalization
  - x= expiration check
  - i= alignment with d= (subdomain-permissive unless DNS t=s set)
  - DNS k= must match signature a=
  - DNS t=y testing-mode -> Unverified(TestingMode)
- Header bottom-up selection per RFC 6376 §5.4 — when h= lists a name
  multiple times we pick distinct occurrences walking from the bottom.
- DKIM-Signature header itself canonicalised with b=value blanked,
  no trailing CRLF, per §3.7. The b= blanker is structural — only
  blanks at top-level tag positions, so a literal 'b=' substring
  inside another tag's base64 is never mistargeted (the bug class
  the PoC PR review flagged).
- Per-signature DkimCheck breakdown so a UI can render which step
  failed.

12 unit tests cover: AUID alignment (exact, subdomain, evil-suffix,
local-only), b= blanking (simple, bh-not-blanked, b-at-start, no-
internal-substring-misblank), no signature, c=simple rejection,
expired signature, misaligned i=. The cryptographic happy-path tests
land alongside the captured/synthetic test vectors in the next commit.

67 DKIM tests pass total (parse 14 + canonicalize 18 + dns_record 16
+ signature 7 + verify 12). Wasm32 build clean.
Lands the synthetic .eml fixtures plus the loader that turns them into
SmtpRequest-shaped data the verifier actually consumes. End-to-end
tests exercise the full pipeline: parse_eml -> SmtpRequest -> verify.

Synthetic vectors were generated offline using dkimpy (Python's
reference DKIM implementation) against a freshly-generated throwaway
2048-bit RSA key. The private key is *not* committed — only the .eml
output and the matching public DKIM TXT record. README in
test_vectors/dkim/ documents provenance and regeneration.

Three .eml files cover:
- c=relaxed/relaxed (the common case for mainstream senders)
- c=relaxed/simple (header relaxed, body simple — accepted)
- c=simple/simple (rejected with UnsupportedCanonicalization)

Eight tests in dkim/test_vectors.rs cover:
- happy-path verification of relaxed/relaxed and relaxed/simple
- simple/simple rejection
- flipped body byte -> BodyHashMismatch
- flipped signature byte -> SignatureInvalid (or related)
- wrong public key -> SignatureInvalid
- no DKIM-Signature header -> NoSignature
- the parse_eml helper itself (sanity check)

The .eml parser unfolds continuation lines per RFC 5322 §2.2.3, drops
the conventional single SP after the colon (matching what the gateway
does in production), and preserves the rest of the header value bytes
verbatim — so relaxed canonicalisation downstream produces the same
form the signer hashed.

Verifier totals: 75 DKIM tests (parse 14, canonicalize 18, dns_record
16, signature 7, verify 12, end-to-end 8). Full II suite: 313 tests
pass (was 238 pre-PR). Wasm32 build clean.
- Use matches!() instead of explicit match arms for KeyType::matches_signature_alg
- rustfmt across all dkim/ files and types/smtp.rs
- Stale test_vectors.rs doc-comment said the throwaway private key 'is committed' — corrected to 'is *not* committed' (the fixtures' provenance README is the source of truth)
- blank_b_tag_value: preserve original bytes around the b= tag instead
  of always emitting literal 'b='. RFC 6376 §3.7 says only the tag
  value (and surrounding WSP) is replaced; the tag name and the bytes
  between the name and '=' must come through verbatim. Concretely,
  signers that emit 'B=' (uppercase) or 'b\t=' / 'b =' (FWS between
  the name and '=') are valid per §3.2 and were previously mis-handled
  — relaxed canonicalisation collapses both forms to 'b:' downstream,
  so the bug only surfaces on signatures from those few senders, but
  by-construction is more honest. New tests cover uppercase B,
  tab-between-name-and-equals, and space-between-name-and-equals.

- test_vectors.rs module-level docstring: corrected to say the private
  key is *not* committed (the README and the actual tree both already
  said this; the docstring was the only contradiction). Dropped the
  reference to scripts/sign-dkim.py since that file isn't checked in.

- smtp.rs validate_smtp_request_envelope_only_ok: tightened the
  assertion from matches!(..., Ok(()) | Err(SmtpResponse::Ok{..}))
  to is_ok(). The Err arm was unreachable (SmtpResponse::Err always
  wraps SmtpRequestError) and would have masked a regression where
  validation mistakenly returned the Ok variant inside Err.
# Conflicts:
#	src/internet_identity/src/main.rs
@sea-snake
Copy link
Copy Markdown
Contributor

Merge conflict has been resolved in the original PR itself now: #3877

@sea-snake sea-snake closed this May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants