checker-email-keys/checker/rules_smimea.go

231 lines
7.2 KiB
Go

package checker
import (
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// SMIMEA-specific rules: field-value validity (usage/selector/matching
// type), certificate parse, validity window, extended key usage, key
// usage flags, signature-algorithm and key-size strength, self-signed
// handling, email SAN/username pairing, and digest-only guidance.
func checkSMIMEABadUsage(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.SMIMEA == nil || d.SMIMEA.Usage <= 3 {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("Unknown SMIMEA usage %d (expected 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE).", d.SMIMEA.Usage),
Hint: "Use usage 3 (DANE-EE) for self-hosted S/MIME certificates.",
}}
}
func checkSMIMEABadSelector(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.SMIMEA == nil || d.SMIMEA.Selector <= 1 {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("Unknown SMIMEA selector %d (expected 0 Cert or 1 SPKI).", d.SMIMEA.Selector),
Hint: "Use selector 0 to publish the full certificate.",
}}
}
func checkSMIMEABadMatchType(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.SMIMEA == nil || d.SMIMEA.MatchingType <= 2 {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("Unknown SMIMEA matching type %d (expected 0 Full, 1 SHA-256, 2 SHA-512).", d.SMIMEA.MatchingType),
Hint: "Use matching type 0 so the whole certificate is transported, or type 1 (SHA-256) for a digest.",
}}
}
func checkSMIMEACertParseError(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.SMIMEA == nil {
return []issue{{
Severity: sdk.StatusCrit,
Message: "Service body has no SMIMEA record.",
Hint: "Attach a valid SMIMEA record to the service.",
}}
}
if d.SMIMEA.ParseError == "" {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: d.SMIMEA.ParseError,
Hint: "Ensure the certificate is DER-encoded (not PEM) before hex-encoding it into SMIMEA RDATA.",
}}
}
func checkSMIMEACertNotYetValid(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
ci := smimeaCert(d)
if ci == nil || ci.NotBefore.IsZero() {
return nil
}
if !time.Now().Before(ci.NotBefore) {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("Certificate is not yet valid (NotBefore = %s).", ci.NotBefore.Format(time.RFC3339)),
Hint: "Check the system clock on the CA/signer, or wait until the certificate's notBefore date.",
}}
}
func checkSMIMEACertExpired(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
ci := smimeaCert(d)
if ci == nil || ci.NotAfter.IsZero() {
return nil
}
if !time.Now().After(ci.NotAfter) {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("Certificate expired on %s.", ci.NotAfter.Format(time.RFC3339)),
Hint: "Issue a fresh certificate and republish the SMIMEA record.",
}}
}
func checkSMIMEACertExpiring(d *EmailKeyData, opts sdk.CheckerOptions) []issue {
ci := smimeaCert(d)
if ci == nil || ci.NotAfter.IsZero() {
return nil
}
warnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30)
if warnDays <= 0 {
return nil
}
now := time.Now()
window := time.Duration(warnDays) * 24 * time.Hour
if ci.NotAfter.Before(now) || ci.NotAfter.Sub(now) >= window {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("Certificate expires on %s.", ci.NotAfter.Format(time.RFC3339)),
Hint: "Renew before expiry and update the SMIMEA record with the new certificate.",
}}
}
func checkSMIMEANoEmailProtect(d *EmailKeyData, opts sdk.CheckerOptions) []issue {
ci := smimeaCert(d)
if ci == nil || ci.HasEmailProtectionEKU {
return nil
}
sev := sdk.StatusWarn
if sdk.GetBoolOption(opts, OptionRequireEmailProtection, true) {
sev = sdk.StatusCrit
}
return []issue{{
Severity: sev,
Message: "Certificate lacks the emailProtection Extended Key Usage; RFC 8550/8551 agents will refuse it.",
Hint: "Re-issue the certificate with `extendedKeyUsage = emailProtection` (OID 1.3.6.1.5.5.7.3.4).",
}}
}
func checkSMIMEAMissingKeyUsage(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
ci := smimeaCert(d)
if ci == nil || ci.HasDigitalSignature || ci.HasKeyEncipherment {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
Message: "Certificate has neither digitalSignature nor keyEncipherment key usage; S/MIME signing or encryption will be refused.",
Hint: "Add `keyUsage = digitalSignature, keyEncipherment` to the certificate profile.",
}}
}
var weakSMIMEASignatureAlgorithms = map[string]bool{
"MD2-RSA": true,
"MD5-RSA": true,
"SHA1-RSA": true,
"DSA-SHA1": true,
"ECDSA-SHA1": true,
}
func checkSMIMEAWeakSigAlgorithm(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
ci := smimeaCert(d)
if ci == nil || ci.SignatureAlgorithm == "" {
return nil
}
if !weakSMIMEASignatureAlgorithms[ci.SignatureAlgorithm] {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("Certificate is signed with %s, a deprecated algorithm.", ci.SignatureAlgorithm),
Hint: "Re-issue the certificate with SHA-256 (or better) signatures.",
}}
}
func checkSMIMEAWeakKeySize(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.SMIMEA == nil {
return nil
}
algo, bits := "", 0
switch {
case d.SMIMEA.Certificate != nil:
algo, bits = d.SMIMEA.Certificate.PublicKeyAlgorithm, d.SMIMEA.Certificate.PublicKeyBits
case d.SMIMEA.PublicKey != nil:
algo, bits = d.SMIMEA.PublicKey.Algorithm, d.SMIMEA.PublicKey.Bits
default:
return nil
}
if iss := rsaKeySizeIssue(algo, bits, "Certificate"); iss != nil {
return []issue{*iss}
}
return nil
}
func checkSMIMEASelfSigned(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
ci := smimeaCert(d)
if ci == nil || !ci.IsSelfSigned {
return nil
}
if d.SMIMEA.Usage != 1 && d.SMIMEA.Usage != 3 {
return nil
}
return []issue{{
Severity: sdk.StatusInfo,
Message: "End-entity usage advertises a self-signed certificate; DANE-EE (usage 3) makes this safe, but PKIX-EE (usage 1) consumers will reject it.",
Hint: "Switch the record to usage 3 (DANE-EE) if you operate your own CA, or chain the certificate under a public CA for usage 1.",
}}
}
func checkSMIMEAEmailMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
ci := smimeaCert(d)
if ci == nil || ci.EmailMatchesUsername == nil || *ci.EmailMatchesUsername {
return nil
}
return []issue{{
Severity: sdk.StatusInfo,
Message: fmt.Sprintf("None of the certificate's email SANs (%s) begin with %s@; clients that strictly match SAN to envelope address will reject it.", strings.Join(ci.EmailAddresses, ", "), d.Username),
Hint: "Re-issue the certificate with the correct `subjectAltName = email:<user>@<domain>`.",
}}
}
func checkSMIMEAHashOnly(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.SMIMEA == nil || d.SMIMEA.MatchingType == 0 {
return nil
}
return []issue{{
Severity: sdk.StatusInfo,
Message: "Record carries only a digest; the certificate itself cannot be verified by this checker.",
Hint: "Switch to matching type 0 (Full) to let verifiers inspect and pin the certificate.",
}}
}
func smimeaCert(d *EmailKeyData) *CertInfo {
if d.SMIMEA == nil {
return nil
}
return d.SMIMEA.Certificate
}