use der::{
asn1::{ObjectIdentifier, Utf8StringRef},
Decode as _,
};
use x509_cert::ext::pkix::name::GeneralName;
use x509_cert::Certificate;
use crate::{truncate_for_detail, Lint, LintResult, Scope, Severity, SubjectKind};
const OID_SUBJECT_ALT_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.29.17");
const ID_ON_SMTP_UTF8_MAILBOX: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.8.9");
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Rfc8398SmimeSanLint;
impl Lint for Rfc8398SmimeSanLint {
fn id(&self) -> &'static str {
"rfc8398.cert.san.smime_mailbox_required"
}
fn citation(&self) -> &'static str {
"RFC 8398 §3 (+ RFC 5280 §4.2.1.6)"
}
fn severity(&self) -> Severity {
Severity::Error
}
fn scope(&self) -> Scope {
Scope::Certificate
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Leaf
}
fn title(&self) -> &str {
"S/MIME certificate must include SAN rfc822Name or SmtpUTF8Mailbox"
}
fn spec_section_id(&self) -> Option<&str> {
Some("rfc8398-3")
}
fn spec_url(&self) -> Option<&str> {
Some("https://www.rfc-editor.org/rfc/rfc8398#section-3")
}
fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
let Some(extensions) = &cert.tbs_certificate.extensions else {
return LintResult::error("leaf certificate has no extensions; SubjectAltName absent");
};
let Some(san_ext) = extensions
.iter()
.find(|e| e.extn_id == OID_SUBJECT_ALT_NAME)
else {
return LintResult::error(
"SubjectAltName extension absent from leaf certificate; RFC 8398 §3 \
requires an rfc822Name or id-on-SmtpUTF8Mailbox otherName entry",
);
};
let san =
match x509_cert::ext::pkix::SubjectAltName::from_der(san_ext.extn_value.as_bytes()) {
Ok(san) => san,
Err(e) => {
let e_str = e.to_string();
let safe_e = truncate_for_detail(&e_str);
return LintResult::error(format!(
"SubjectAltName extension value is malformed DER: {safe_e}"
));
}
};
if san.0.is_empty() {
return LintResult::error("SubjectAltName extension is present but contains no names");
}
let has_mailbox = san.0.iter().any(|gn| match gn {
GeneralName::Rfc822Name(_) => true,
GeneralName::OtherName(on) => on.type_id == ID_ON_SMTP_UTF8_MAILBOX,
_ => false,
});
if has_mailbox {
LintResult::Pass
} else {
LintResult::error(
"SubjectAltName does not contain an rfc822Name or id-on-SmtpUTF8Mailbox \
otherName; RFC 8398 §3 requires at least one for S/MIME identity",
)
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Rfc8398SmimeMailboxEquivalenceLint;
impl Lint for Rfc8398SmimeMailboxEquivalenceLint {
fn id(&self) -> &'static str {
"rfc8398.cert.san.smime_mailbox_equivalent"
}
fn citation(&self) -> &'static str {
"RFC 8398 §3"
}
fn severity(&self) -> Severity {
Severity::Error
}
fn scope(&self) -> Scope {
Scope::Certificate
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Leaf
}
fn title(&self) -> &str {
"rfc822Name and SmtpUTF8Mailbox SAN entries must agree on the same address"
}
fn spec_section_id(&self) -> Option<&str> {
Some("rfc8398-3")
}
fn spec_url(&self) -> Option<&str> {
Some("https://www.rfc-editor.org/rfc/rfc8398#section-3")
}
fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
let Some(extensions) = &cert.tbs_certificate.extensions else {
return LintResult::Pass;
};
let Some(san_ext) = extensions
.iter()
.find(|e| e.extn_id == OID_SUBJECT_ALT_NAME)
else {
return LintResult::Pass;
};
let san =
match x509_cert::ext::pkix::SubjectAltName::from_der(san_ext.extn_value.as_bytes()) {
Ok(san) => san,
Err(e) => {
let e_str = e.to_string();
let safe_e = truncate_for_detail(&e_str);
return LintResult::error(format!(
"SubjectAltName extension value is malformed DER: {safe_e}"
));
}
};
let mut rfc822_addrs: Vec<&str> = Vec::new();
let mut smtputf8_addrs: Vec<String> = Vec::new();
for gn in &san.0 {
match gn {
GeneralName::Rfc822Name(addr) => {
rfc822_addrs.push(addr.as_str());
}
GeneralName::OtherName(on) if on.type_id == ID_ON_SMTP_UTF8_MAILBOX => {
match Utf8StringRef::try_from(&on.value) {
Ok(s) => smtputf8_addrs.push(s.to_string()),
Err(_) => {
return LintResult::error(
"SubjectAltName id-on-SmtpUTF8Mailbox otherName value \
is not a valid UTF8String",
);
}
}
}
_ => {}
}
}
if rfc822_addrs.is_empty() || smtputf8_addrs.is_empty() {
return LintResult::Pass;
}
for r in &rfc822_addrs {
if let Err(reason) = validate_mailbox_for_equivalence(r) {
let safe_r = truncate_for_detail(r);
return LintResult::error(format!(
"rfc822Name SAN entry '{safe_r}' is malformed for equivalence checking \
({reason}); RFC 8398 §3 presupposes well-formed RFC 5322 mailbox \
addresses"
));
}
}
for u in &smtputf8_addrs {
if let Err(reason) = validate_mailbox_for_equivalence(u) {
let safe_u = truncate_for_detail(u);
return LintResult::error(format!(
"SmtpUTF8Mailbox SAN entry '{safe_u}' is malformed for equivalence checking \
({reason}); RFC 8398 §3 presupposes well-formed RFC 5322 mailbox \
addresses"
));
}
}
for r in &rfc822_addrs {
if !smtputf8_addrs.iter().any(|u| {
mailbox_equivalent(r, u)
.expect("pre-validation ensures inputs are well-formed")
}) {
let safe_r = truncate_for_detail(r);
return LintResult::error(format!(
"rfc822Name SAN entry '{safe_r}' has no matching SmtpUTF8Mailbox; \
RFC 8398 §3 requires byte-identical local-part and \
A-label/U-label-equivalent domain"
));
}
}
for u in &smtputf8_addrs {
if !rfc822_addrs.iter().any(|r| {
mailbox_equivalent(r, u)
.expect("pre-validation ensures inputs are well-formed")
}) {
let safe_u = truncate_for_detail(u);
return LintResult::error(format!(
"SmtpUTF8Mailbox SAN entry '{safe_u}' has no matching rfc822Name; \
RFC 8398 §3 requires byte-identical local-part and \
A-label/U-label-equivalent domain"
));
}
}
LintResult::Pass
}
}
fn validate_mailbox_for_equivalence(addr: &str) -> Result<(), &'static str> {
let (local, domain) = split_mailbox(addr).ok_or("no '@' delimiter")?;
if local.is_empty() {
return Err("empty local-part");
}
if domain.is_empty() {
return Err("empty domain");
}
idna::domain_to_ascii(domain).map_err(|_| "domain failed IDN A-label conversion")?;
Ok(())
}
fn mailbox_equivalent(rfc822: &str, smtputf8: &str) -> Result<bool, &'static str> {
let (r_local, r_domain) = split_mailbox(rfc822).ok_or("rfc822Name has no '@' delimiter")?;
let (u_local, u_domain) =
split_mailbox(smtputf8).ok_or("SmtpUTF8Mailbox has no '@' delimiter")?;
if r_local.is_empty() || u_local.is_empty() || r_domain.is_empty() || u_domain.is_empty() {
return Err("mailbox address has empty local-part or domain");
}
if r_local != u_local {
return Ok(false);
}
let u_ascii = idna::domain_to_ascii(u_domain).map_err(|_| "U-label → A-label conversion failed")?;
Ok(u_ascii.eq_ignore_ascii_case(r_domain))
}
fn split_mailbox(addr: &str) -> Option<(&str, &str)> {
let idx = addr.rfind('@')?;
Some((&addr[..idx], &addr[idx + 1..]))
}
#[cfg(test)]
mod tests {
use super::*;
fn load_cert(name: &str) -> Certificate {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../pkix-path/tests/fixtures/policy-checks/")
.join(name);
let der =
std::fs::read(&path).unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()));
<Certificate as der::Decode>::from_der(&der)
.unwrap_or_else(|e| panic!("decode fixture {name}: {e}"))
}
#[test]
fn smime_san_lint_accepts_rfc822_san() {
let lint = Rfc8398SmimeSanLint;
let cert = load_cert("smime-self-signed-365d.der");
assert_eq!(
lint.check_cert(&cert, SubjectKind::Leaf, 0),
LintResult::Pass
);
}
#[test]
fn smime_san_lint_rejects_missing_san() {
let lint = Rfc8398SmimeSanLint;
let cert = load_cert("leaf-p256-365d-no-san.der");
match lint.check_cert(&cert, SubjectKind::Leaf, 0) {
LintResult::Error(detail) => {
assert!(
detail.contains("SubjectAltName extension absent"),
"error detail must mention missing SAN; got: {detail}"
);
}
other => panic!("expected Error, got: {other:?}"),
}
}
#[test]
fn smime_san_lint_rejects_san_without_mailbox() {
let lint = Rfc8398SmimeSanLint;
let cert = load_cert("leaf-p256-365d-san-eku.der");
match lint.check_cert(&cert, SubjectKind::Leaf, 0) {
LintResult::Error(detail) => {
assert!(
detail.contains("rfc822Name") || detail.contains("SmtpUTF8Mailbox"),
"error detail must name the required SAN types; got: {detail}"
);
}
other => panic!("expected Error, got: {other:?}"),
}
}
#[test]
fn smime_san_lint_metadata_matches_rfc_section() {
let lint = Rfc8398SmimeSanLint;
assert_eq!(lint.id(), "rfc8398.cert.san.smime_mailbox_required");
assert_eq!(lint.citation(), "RFC 8398 §3 (+ RFC 5280 §4.2.1.6)");
assert_eq!(lint.severity(), Severity::Error);
assert_eq!(lint.scope(), Scope::Certificate);
assert_eq!(lint.applies_to(), SubjectKind::Leaf);
assert_eq!(lint.spec_section_id(), Some("rfc8398-3"));
assert_eq!(
lint.spec_url(),
Some("https://www.rfc-editor.org/rfc/rfc8398#section-3")
);
}
#[test]
fn smime_mailbox_equiv_lint_passes_on_rfc822_only_fixture() {
let lint = Rfc8398SmimeMailboxEquivalenceLint;
let cert = load_cert("smime-self-signed-365d.der");
assert_eq!(
lint.check_cert(&cert, SubjectKind::Leaf, 0),
LintResult::Pass
);
}
#[test]
fn smime_mailbox_equiv_lint_passes_when_san_absent() {
let lint = Rfc8398SmimeMailboxEquivalenceLint;
let cert = load_cert("leaf-p256-365d-no-san.der");
assert_eq!(
lint.check_cert(&cert, SubjectKind::Leaf, 0),
LintResult::Pass
);
}
#[test]
fn smime_mailbox_equiv_lint_passes_when_only_dns_san() {
let lint = Rfc8398SmimeMailboxEquivalenceLint;
let cert = load_cert("leaf-p256-365d-san-eku.der");
assert_eq!(
lint.check_cert(&cert, SubjectKind::Leaf, 0),
LintResult::Pass
);
}
#[test]
fn smime_mailbox_equiv_lint_metadata_matches_rfc_section() {
let lint = Rfc8398SmimeMailboxEquivalenceLint;
assert_eq!(lint.id(), "rfc8398.cert.san.smime_mailbox_equivalent");
assert_eq!(lint.citation(), "RFC 8398 §3");
assert_eq!(lint.severity(), Severity::Error);
assert_eq!(lint.scope(), Scope::Certificate);
assert_eq!(lint.applies_to(), SubjectKind::Leaf);
assert_eq!(lint.spec_section_id(), Some("rfc8398-3"));
assert_eq!(
lint.spec_url(),
Some("https://www.rfc-editor.org/rfc/rfc8398#section-3")
);
}
#[test]
fn mailbox_equiv_ascii_identical() {
assert_eq!(
mailbox_equivalent("alice@example.com", "alice@example.com"),
Ok(true)
);
}
#[test]
fn mailbox_equiv_domain_case_insensitive() {
assert_eq!(
mailbox_equivalent("alice@Example.COM", "alice@example.com"),
Ok(true)
);
}
#[test]
fn mailbox_equiv_local_part_case_sensitive() {
assert_eq!(
mailbox_equivalent("Alice@example.com", "alice@example.com"),
Ok(false)
);
}
#[test]
fn mailbox_equiv_idn_a_label_matches_u_label() {
assert_eq!(
mailbox_equivalent("alice@xn--caf-dma.example", "alice@café.example"),
Ok(true)
);
}
#[test]
fn mailbox_equiv_idn_a_label_matches_u_label_mixed_case() {
assert_eq!(
mailbox_equivalent("alice@XN--CAF-DMA.example", "alice@café.example"),
Ok(true)
);
}
#[test]
fn mailbox_equiv_different_local_part_rejects() {
assert_eq!(
mailbox_equivalent("alice@example.com", "bob@example.com"),
Ok(false)
);
}
#[test]
fn mailbox_equiv_different_domain_rejects() {
assert_eq!(
mailbox_equivalent("alice@example.com", "alice@other.com"),
Ok(false)
);
}
#[test]
fn mailbox_equiv_missing_at_errors() {
assert!(mailbox_equivalent("alice", "alice@example.com").is_err());
assert!(mailbox_equivalent("alice@example.com", "alice").is_err());
}
#[test]
fn mailbox_equiv_empty_local_or_domain_errors() {
assert!(mailbox_equivalent("@example.com", "@example.com").is_err());
assert!(mailbox_equivalent("alice@", "alice@").is_err());
}
#[test]
fn validate_mailbox_accepts_well_formed_ascii() {
assert!(validate_mailbox_for_equivalence("alice@example.com").is_ok());
}
#[test]
fn validate_mailbox_accepts_well_formed_u_label() {
assert!(validate_mailbox_for_equivalence("alice@café.example").is_ok());
}
#[test]
fn validate_mailbox_rejects_missing_at() {
assert!(validate_mailbox_for_equivalence("alice").is_err());
}
#[test]
fn validate_mailbox_rejects_empty_local_part() {
assert!(validate_mailbox_for_equivalence("@example.com").is_err());
}
#[test]
fn validate_mailbox_rejects_empty_domain() {
assert!(validate_mailbox_for_equivalence("alice@").is_err());
}
}