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:
co-authored by
Claude Opus 5
parent
428040d964
commit
36b15ddcaf
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user