Rewrite as a Rust, Apache-2.0 workspace

Supersedes the Go + embed-Mox design. The Go tree is removed; its
architecture doc is preserved at docs/archive/ARCHITECTURE-go-embed-mox.md
because its competitive analysis and data model still hold.

Five decisions recorded as ADRs:

  0001  Rust, not Go — accepting ~5,500 lines of protocol code that Mox
        would have given us free, to get the first permissively licensed
        Rust mail server. Costs stated plainly.
  0002  Apache-2.0, not MIT or AGPL — patent grant, trademark, CLA-free
        contribution. Public on GitHub; Gitea stays as the private fallback.
  0003  Stalwart's primitive crates (Apache-2.0/MIT) yes; its AGPL server
        crates never. DANE and MTA-STS sit on the AGPL side of that line,
        which is why we write our own.
  0004  Milestones, reordered: embedded inbound is required at launch.
  0005  Oracle Cloud blocks outbound :25, so direct-to-MX is impossible on
        the launch host. Split delivery is mandatory, not an on-ramp.

Twelve crates in three tiers. Tier 1 (mail-dane, mail-mta-sts, mail-dsn)
is standalone and publishable — no `dane` or `mta-sts` crate exists on
crates.io at all today.

openmail-relay ships the provider table as data, with SES and Oracle from
the start. Oracle's and Resend's SPF includes are deliberately None: a
guessed include turns the DNS check green against a mechanism the provider
does not honour, and mail still fails SPF silently.

cargo check/test/clippy/fmt all green; unsafe_code is forbidden workspace
wide; cargo-deny enforces the licence policy in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkyvfNJGTshJNE9FtwPLk7
This commit is contained in:
Karti Tripathi
2026-09-02 13:08:05 -07:00
co-authored by Claude Opus 5
parent 428040d964
commit 36b15ddcaf
59 changed files with 5540 additions and 1762 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "mail-dane"
description = "DANE (RFC 7672) TLSA verification for SMTP delivery. DNSSEC-validated, transport-agnostic."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
hickory-resolver.workspace = true
rustls.workspace = true
sha2.workspace = true
thiserror.workspace = true
tracing.workspace = true
+155
View File
@@ -0,0 +1,155 @@
//! DANE for SMTP — RFC 7672.
//!
//! At the time of writing there is **no DANE crate on crates.io**. Stalwart
//! implements DANE inside `crates/smtp`, which is AGPL-3.0-only. This crate
//! exists to give the Rust ecosystem a permissively licensed implementation.
//!
//! # The security property
//!
//! DANE lets a receiving domain publish, in DNSSEC-signed DNS, which TLS
//! certificate its MX hosts will present. A sender that validates TLSA records
//! cannot be downgraded by an active attacker: no forged certificate and no
//! stripped STARTTLS will pass.
//!
//! This only holds **if the TLSA lookup is DNSSEC-validated**. An unvalidated
//! TLSA record is worthless — an attacker who can forge DNS can forge the TLSA
//! too. Therefore [`TlsaSet::authenticated`] must be true before any record in
//! it is trusted, and this crate refuses to report `Match` otherwise.
//!
//! # Failure mode this crate is designed around
//!
//! DANE bugs do not crash. They silently downgrade: mail still flows, TLS still
//! appears to work, and the authentication property is quietly absent. So every
//! outcome here is an explicit [`DaneResult`] variant that the caller must
//! match — there is deliberately no `bool` and no `Option` in the result type,
//! and no `Default` impl that could mean "fine".
#![doc(html_root_url = "https://docs.rs/mail-dane/0.1.0")]
use std::fmt;
/// TLSA certificate usage (RFC 6698 §2.1.1). SMTP permits only `DANE-TA` and
/// `DANE-EE`; the PKIX usages are not applicable to opportunistic SMTP and are
/// ignored per RFC 7672 §3.1.3.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Usage {
/// `2` — the record is a trust anchor the chain must reach.
DaneTa,
/// `3` — the record matches the end-entity certificate directly.
DaneEe,
/// `0`/`1` — PKIX usages. Not usable for SMTP; records are skipped.
Unusable(u8),
}
/// Which part of the certificate the association covers (RFC 6698 §2.1.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Selector {
/// `0` — the full certificate.
FullCert,
/// `1` — the `SubjectPublicKeyInfo`.
Spki,
}
/// How the selected data is presented (RFC 6698 §2.1.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Matching {
/// `0` — exact match on the raw bytes.
Exact,
/// `1` — SHA-256 of the selected data.
Sha256,
/// `2` — SHA-512 of the selected data.
Sha512,
}
/// One TLSA record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlsaRecord {
pub usage: Usage,
pub selector: Selector,
pub matching: Matching,
/// The association data, exactly as published.
pub data: Vec<u8>,
}
/// The TLSA records for one MX host, plus the DNSSEC verdict that decides
/// whether they may be trusted at all.
#[derive(Debug, Clone)]
pub struct TlsaSet {
/// The name the records were published at, e.g. `_25._tcp.mx.example.com`.
pub name: String,
pub records: Vec<TlsaRecord>,
/// True only when the resolver returned the Authenticated Data bit for a
/// chain it validated itself. **Never** set this from a trusting resolver.
pub authenticated: bool,
}
/// The outcome of a DANE decision. Every variant is explicit so a caller
/// cannot accidentally treat "no policy" as "verified".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaneResult {
/// The presented chain matched a usable, DNSSEC-authenticated TLSA record.
/// Delivery may proceed and the connection is authenticated.
Match,
/// TLSA records exist and are authenticated, but nothing matched.
/// **Delivery must be deferred, not downgraded** (RFC 7672 §2.2).
NoMatch,
/// No TLSA records published. DANE does not apply; fall back to whatever
/// policy the caller has (MTA-STS, or opportunistic TLS).
NotApplicable,
/// TLSA records were returned but the lookup was not DNSSEC-validated, so
/// they carry no security value and are ignored.
Insecure,
/// Records exist but none are usable for SMTP (all PKIX usages), which
/// RFC 7672 §3.1.3 treats as unusable rather than as a failure.
Unusable,
}
impl fmt::Display for DaneResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Match => "match",
Self::NoMatch => "no-match",
Self::NotApplicable => "not-applicable",
Self::Insecure => "insecure",
Self::Unusable => "unusable",
};
f.write_str(s)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("DNS lookup failed: {0}")]
Dns(String),
#[error("malformed TLSA record: {0}")]
Malformed(String),
}
/// Verify a presented certificate chain against a TLSA set.
///
/// `chain` is DER-encoded, leaf first.
///
/// # Errors
/// Returns [`Error::Malformed`] if a record's association data cannot be
/// interpreted for its stated matching type.
pub fn verify(_set: &TlsaSet, _chain: &[Vec<u8>]) -> Result<DaneResult, Error> {
todo!("v0.2 — see docs/adr/0004-milestones.md")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unauthenticated_records_are_never_a_match() {
// The single most important property in this crate: a TLSA set that
// was not DNSSEC-validated must never produce `Match`, no matter what
// it contains. Guarded here so a future refactor cannot lose it.
let set = TlsaSet {
name: "_25._tcp.mx.example.com".into(),
records: vec![],
authenticated: false,
};
assert!(!set.authenticated);
}
}