package checker import ( "bytes" "context" "crypto/dsa" "crypto/ecdsa" "crypto/ed25519" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "fmt" "strings" "time" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/miekg/dns" sdk "git.happydns.org/checker-sdk-go/checker" ) // serviceBody is the common envelope for the two services. type serviceBody struct { Username string `json:"username,omitempty"` OpenPGP *dns.OPENPGPKEY `json:"openpgpkey,omitempty"` SMIMEA *dns.SMIMEA `json:"smimea,omitempty"` } // Collect runs the full DANE-email testsuite and returns an *EmailKeyData // carrying every finding it produced. The function never returns an error // for domain-level problems; they are recorded as findings so that a // subsequent call from the rule can fold them into a single CheckState. // A non-nil error is returned only for unrecoverable input problems // (missing options, unknown service type). func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { svcMsg, err := serviceFromOptions(opts) if err != nil { return nil, err } kind := kindForServiceType(svcMsg.Type) if kind == "" { return nil, fmt.Errorf("service type %q is not supported by this checker", svcMsg.Type) } var body serviceBody if err := json.Unmarshal(svcMsg.Service, &body); err != nil { return nil, fmt.Errorf("decode service body: %w", err) } originOpt, _ := sdk.GetOption[string](opts, "domain_name") subdomainOpt, _ := sdk.GetOption[string](opts, "subdomain") resolverOpt, _ := sdk.GetOption[string](opts, OptionResolver) expiryWarnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30) requireDNSSEC := sdk.GetBoolOption(opts, OptionRequireDNSSEC, true) requireEmailProtection := sdk.GetBoolOption(opts, OptionRequireEmailProtection, true) origin := strings.TrimSuffix(firstNonEmpty(originOpt, svcMsg.Domain), ".") if origin == "" { return nil, fmt.Errorf("missing 'domain_name' option") } parent := joinSubdomain(subdomainOpt, origin) data := &EmailKeyData{ Kind: kind, Domain: dns.Fqdn(origin), Subdomain: strings.TrimSuffix(subdomainOpt, "."), Username: body.Username, CollectedAt: time.Now().UTC(), } prefix := OpenPGPKeyPrefix if kind == KindSMIMEA { prefix = SMIMEACertPrefix } expectedOwner, recordedOwner := computeOwner(body, prefix, parent) data.ExpectedOwner = expectedOwner data.QueriedOwner = firstNonEmpty(recordedOwner, expectedOwner) // Username hash prefix verification (RFC 7929 §3, RFC 8162 §3). if data.Username != "" { actualPrefix, want := extractOwnerPrefix(data.QueriedOwner, prefix, parent), ownerHashHex(data.Username) if actualPrefix != "" && !strings.EqualFold(actualPrefix, want) { data.Findings = append(data.Findings, Finding{ Code: CodeOwnerHashMismatch, Severity: SeverityCrit, Message: fmt.Sprintf("Owner name prefix %q does not match SHA-256(%q)[:28]=%q.", actualPrefix, data.Username, want), Fix: "Republish the record at the hash-derived name for the intended user, or update the Username field to match the record's owner name.", }) } } // DNS lookup + DNSSEC flag. if data.QueriedOwner != "" { servers := resolvers(resolverOpt) qtype := dns.TypeOPENPGPKEY if kind == KindSMIMEA { qtype = dns.TypeSMIMEA } ans, err := lookup(ctx, servers, data.QueriedOwner, qtype) if err != nil { data.Findings = append(data.Findings, Finding{ Code: CodeDNSQueryFailed, Severity: SeverityCrit, Message: fmt.Sprintf("DNS lookup for %s %s failed: %v", dns.TypeToString[qtype], data.QueriedOwner, err), Fix: "Check that the zone is published at an authoritative server reachable from this checker.", }) } else { data.Resolver = ans.Server secure := ans.AD data.DNSSECSecure = &secure data.RecordCount = len(ans.Records) if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 { data.Findings = append(data.Findings, Finding{ Code: CodeDNSNoRecord, Severity: SeverityCrit, Message: fmt.Sprintf("Authoritative DNS returned no %s record at %s.", dns.TypeToString[qtype], data.QueriedOwner), Fix: "Ensure the record is present in the zone and that the zone has been loaded by the authoritative servers.", }) } else { if !ans.AD { sev := SeverityWarn if requireDNSSEC { sev = SeverityCrit } data.Findings = append(data.Findings, Finding{ Code: CodeDNSNotSecure, Severity: sev, Message: "The validating resolver did not set the AD flag: the record is not DNSSEC-authenticated, which defeats the whole DANE trust model.", Fix: "Sign the zone with DNSSEC and publish the DS record at the parent so RFC 7929/8162 consumers can authenticate the key.", }) } // Compare observed record with the service-published one. mismatch := false if kind == KindOpenPGPKey && body.OpenPGP != nil { mismatch = !anyOpenPGPMatches(ans.Records, body.OpenPGP) } else if kind == KindSMIMEA && body.SMIMEA != nil { mismatch = !anySMIMEAMatches(ans.Records, body.SMIMEA) } if mismatch { data.Findings = append(data.Findings, Finding{ Code: CodeDNSRecordMismatch, Severity: SeverityWarn, Message: "The record returned by DNS does not match the one declared in the service. The zone may not have been re-published since the last edit.", Fix: "Propagate the zone to the authoritative servers, then wait for TTL/negative-cache expiry.", }) } } } } // Parse the payload from the service body (so we can analyze even if // DNS lookup failed to reach the authoritative servers). if kind == KindOpenPGPKey { data.OpenPGP, data.Findings = analyzeOpenPGP(body, data.Findings, time.Duration(expiryWarnDays)*24*time.Hour) } else { data.SMIMEA, data.Findings = analyzeSMIMEA(body, data.Findings, time.Duration(expiryWarnDays)*24*time.Hour, requireEmailProtection) } return data, nil } // serviceFromOptions pulls the "service" option out of the options map, // accepting both the in-process plugin path (native Go value) and the // HTTP path (JSON-decoded map[string]any). Normalising via a JSON // round-trip keeps both paths working without importing the upstream // type. func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) { v, ok := opts["service"] if !ok { return nil, fmt.Errorf("service option missing") } raw, err := json.Marshal(v) if err != nil { return nil, fmt.Errorf("marshal service option: %w", err) } var svc serviceMessage if err := json.Unmarshal(raw, &svc); err != nil { return nil, fmt.Errorf("decode service option: %w", err) } // Fall back to the service_type option when the envelope doesn't // carry _svctype (older hosts). if svc.Type == "" { if st, ok := sdk.GetOption[string](opts, "service_type"); ok { svc.Type = st } } return &svc, nil } func kindForServiceType(t string) string { switch t { case ServiceOpenPGP: return KindOpenPGPKey case ServiceSMimeCert: return KindSMIMEA default: return "" } } // ownerHashHex returns the RFC 7929 / 8162 label: hex(sha256(localpart)[:28]). func ownerHashHex(username string) string { sum := sha256.Sum256([]byte(username)) return hex.EncodeToString(sum[:DANEOwnerHashSize]) } // computeOwner derives the expected FQDN from the service body. It // returns the expected-by-specification owner and, when the service // body carries its own Hdr.Name, the recorded owner, so we can detect // discrepancies between the two. func computeOwner(body serviceBody, prefix, parent string) (expected, recorded string) { if body.Username != "" { expected = dns.Fqdn(ownerHashHex(body.Username) + "." + strings.TrimPrefix(prefix, "") + "." + strings.TrimSuffix(parent, ".")) // Normalise: no double dots. expected = strings.Replace(expected, "..", ".", -1) } switch { case body.OpenPGP != nil && body.OpenPGP.Hdr.Name != "": recorded = dns.Fqdn(body.OpenPGP.Hdr.Name) case body.SMIMEA != nil && body.SMIMEA.Hdr.Name != "": recorded = dns.Fqdn(body.SMIMEA.Hdr.Name) } return } // extractOwnerPrefix pulls the leading label from an owner name of the // form ._openpgpkey.<...> (or _smimecert), returning the hash // portion only. Returns "" when the owner does not follow that shape. func extractOwnerPrefix(owner, prefix, parent string) string { owner = strings.TrimSuffix(strings.ToLower(owner), ".") // Look for ".." just after the first label. marker := "." + prefix + "." if i := strings.Index(owner, marker); i > 0 { return owner[:i] } return "" } // anyOpenPGPMatches reports whether any of rrs carries the same public // key bytes as ref. func anyOpenPGPMatches(rrs []dns.RR, ref *dns.OPENPGPKEY) bool { want := strings.TrimSpace(ref.PublicKey) for _, rr := range rrs { if r, ok := rr.(*dns.OPENPGPKEY); ok && strings.TrimSpace(r.PublicKey) == want { return true } } return false } // anySMIMEAMatches reports whether any of rrs matches ref on (usage, // selector, matching type, certificate bytes). func anySMIMEAMatches(rrs []dns.RR, ref *dns.SMIMEA) bool { want := strings.ToLower(strings.TrimSpace(ref.Certificate)) for _, rr := range rrs { r, ok := rr.(*dns.SMIMEA) if !ok { continue } if r.Usage == ref.Usage && r.Selector == ref.Selector && r.MatchingType == ref.MatchingType && strings.ToLower(strings.TrimSpace(r.Certificate)) == want { return true } } return false } // ── OpenPGP analysis ───────────────────────────────────────────────────────── // analyzeOpenPGP parses the OpenPGP key from the service record and // emits findings. It returns the summary and the (possibly extended) // findings list. func analyzeOpenPGP(body serviceBody, findings []Finding, expiryWarn time.Duration) (*OpenPGPInfo, []Finding) { if body.OpenPGP == nil { findings = append(findings, Finding{ Code: CodePGPParseError, Severity: SeverityCrit, Message: "Service body has no OPENPGPKEY record.", Fix: "Attach a valid OPENPGPKEY record to the service.", }) return nil, findings } raw, err := base64.StdEncoding.DecodeString(body.OpenPGP.PublicKey) if err != nil { findings = append(findings, Finding{ Code: CodePGPParseError, Severity: SeverityCrit, Message: fmt.Sprintf("OPENPGPKEY record carries invalid base64: %v", err), Fix: "Re-export the public key as a binary OpenPGP packet stream (no ASCII armor) and base64 it exactly as stored in the RDATA.", }) return nil, findings } info := &OpenPGPInfo{RawSize: len(raw)} // Large records get fragmented over UDP and force TCP re-queries. // RFC 7929 is silent on the exact threshold; >1200 bytes is a // reasonable "will not fit in a typical UDP answer" line. if len(raw) > 4096 { findings = append(findings, Finding{ Code: CodePGPRecordTooLarge, Severity: SeverityWarn, Message: fmt.Sprintf("The OpenPGP key packet is %d bytes. Large records force every resolver to fall back to TCP, slowing down the DANE lookup.", len(raw)), Fix: "Publish only the minimum key material needed for email encryption (primary + encryption subkey) and strip image UIDs / extra attributes before export.", }) } entities, err := openpgp.ReadKeyRing(bytes.NewReader(raw)) if err != nil || len(entities) == 0 { // Fallback: try parsing as a single packet stream; some // implementations omit markers between entities. if err == nil { err = fmt.Errorf("no OpenPGP entity found") } findings = append(findings, Finding{ Code: CodePGPParseError, Severity: SeverityCrit, Message: fmt.Sprintf("Cannot parse OpenPGP key: %v", err), Fix: "Regenerate the key with `gpg --export | base64` and paste the result; do not armor the key.", }) return info, findings } info.EntityCount = len(entities) if len(entities) > 1 { findings = append(findings, Finding{ Code: CodePGPMultipleEntities, Severity: SeverityWarn, Message: fmt.Sprintf("The record contains %d OpenPGP entities; RFC 7929 recommends a single entity per OPENPGPKEY record.", len(entities)), Fix: "Split each user's key into its own OPENPGPKEY RR.", }) } ent := entities[0] pub := ent.PrimaryKey info.CreatedAt = pub.CreationTime info.Fingerprint = strings.ToUpper(hex.EncodeToString(pub.Fingerprint)) info.KeyID = fmt.Sprintf("%016X", pub.KeyId) info.PrimaryAlgorithm = algorithmName(pub) info.PrimaryBits = publicKeyBits(pub) // Identity UIDs. for name := range ent.Identities { info.UIDs = append(info.UIDs, name) } // Revocations on the primary key. if len(ent.Revocations) > 0 { info.Revoked = true findings = append(findings, Finding{ Code: CodePGPRevoked, Severity: SeverityCrit, Message: "The OpenPGP primary key carries a revocation signature. Consumers will refuse to encrypt to it.", Fix: "Publish a fresh, non-revoked key at this name, or withdraw the OPENPGPKEY record entirely.", }) } // Expiry on the primary key, derived from the self-signature. now := time.Now() if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil { if selfSig.KeyLifetimeSecs != nil && *selfSig.KeyLifetimeSecs > 0 { exp := pub.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second) info.ExpiresAt = exp if exp.Before(now) { findings = append(findings, Finding{ Code: CodePGPExpired, Severity: SeverityCrit, Message: fmt.Sprintf("The OpenPGP primary key expired on %s.", exp.Format(time.RFC3339)), Fix: "Extend the key's expiry (`gpg --edit-key ` → `expire`) or issue a new key and republish the OPENPGPKEY record.", }) } else if expiryWarn > 0 && exp.Sub(now) < expiryWarn { findings = append(findings, Finding{ Code: CodePGPExpiringSoon, Severity: SeverityWarn, Message: fmt.Sprintf("The OpenPGP primary key expires on %s.", exp.Format(time.RFC3339)), Fix: "Extend the key's expiry before it lapses, then re-export and republish.", }) } } } // Identity presence and UID vs username matching. if len(ent.Identities) == 0 { findings = append(findings, Finding{ Code: CodePGPNoIdentity, Severity: SeverityWarn, Message: "The OpenPGP key has no self-signed User ID. Most clients require at least one identity to bind the key to an email address.", Fix: "Add a UID containing the user's email (e.g. `gpg --edit-key ` → `adduid`) and re-export.", }) } else if body.Username != "" { wantedLocal := strings.ToLower(body.Username) matched := false for name := range ent.Identities { if strings.Contains(strings.ToLower(name), "<"+wantedLocal+"@") || strings.Contains(strings.ToLower(name), wantedLocal+"@") { matched = true break } } if !matched { findings = append(findings, Finding{ Code: CodePGPUIDMismatch, Severity: SeverityInfo, Message: fmt.Sprintf("None of the OpenPGP UIDs reference <%s@…>.", body.Username), Fix: "Add a UID bound to the email address that the record attests to.", }) } } // Primary key algorithm + size checks. if warn := pgpAlgorithmWarning(pub); warn != nil { findings = append(findings, *warn) } // Subkeys + encryption capability. for _, sk := range ent.Subkeys { si := SubkeyInfo{ Algorithm: algorithmName(sk.PublicKey), Bits: publicKeyBits(sk.PublicKey), CreatedAt: sk.PublicKey.CreationTime, Revoked: len(sk.Revocations) > 0, } if sk.Sig != nil { if sk.Sig.FlagsValid { si.CanSign = sk.Sig.FlagSign si.CanEncrypt = sk.Sig.FlagEncryptCommunications || sk.Sig.FlagEncryptStorage si.CanAuth = sk.Sig.FlagAuthenticate } if sk.Sig.KeyLifetimeSecs != nil && *sk.Sig.KeyLifetimeSecs > 0 { si.ExpiresAt = sk.PublicKey.CreationTime.Add(time.Duration(*sk.Sig.KeyLifetimeSecs) * time.Second) } } info.Subkeys = append(info.Subkeys, si) if si.CanEncrypt && !si.Revoked && (si.ExpiresAt.IsZero() || si.ExpiresAt.After(now)) { info.HasEncryptionCapability = true } if warn := pgpAlgorithmWarning(sk.PublicKey); warn != nil { findings = append(findings, *warn) } } // Primary can also be an encryption key if flagged so. if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil && selfSig.FlagsValid && (selfSig.FlagEncryptCommunications || selfSig.FlagEncryptStorage) && !info.Revoked && (info.ExpiresAt.IsZero() || info.ExpiresAt.After(now)) { info.HasEncryptionCapability = true } if !info.HasEncryptionCapability { findings = append(findings, Finding{ Code: CodePGPNoEncryption, Severity: SeverityCrit, Message: "No active (non-revoked, non-expired) key in the entity advertises encryption capability. The record is useless for email encryption.", Fix: "Generate an encryption subkey (`gpg --edit-key ` → `addkey`) and re-export.", }) } return info, findings } func algorithmName(pub *packet.PublicKey) string { switch pub.PubKeyAlgo { case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSAEncryptOnly, packet.PubKeyAlgoRSASignOnly: return "RSA" case packet.PubKeyAlgoDSA: return "DSA" case packet.PubKeyAlgoElGamal: return "ElGamal" case packet.PubKeyAlgoECDH: return "ECDH" case packet.PubKeyAlgoECDSA: return "ECDSA" case packet.PubKeyAlgoEdDSA: return "EdDSA" case packet.PubKeyAlgoX25519: return "X25519" case packet.PubKeyAlgoX448: return "X448" case packet.PubKeyAlgoEd25519: return "Ed25519" case packet.PubKeyAlgoEd448: return "Ed448" default: return fmt.Sprintf("algo-%d", pub.PubKeyAlgo) } } func publicKeyBits(pub *packet.PublicKey) int { if pub == nil { return 0 } switch k := pub.PublicKey.(type) { case *rsa.PublicKey: if k == nil || k.N == nil { return 0 } return k.N.BitLen() case *dsa.PublicKey: if k == nil || k.P == nil { return 0 } return k.P.BitLen() case *ecdsa.PublicKey: if k == nil || k.Params() == nil { return 0 } return k.Params().BitSize case ed25519.PublicKey: return 256 } // Fallback to the packet's advertised length. if n, err := pub.BitLength(); err == nil { return int(n) } return 0 } func pgpAlgorithmWarning(pub *packet.PublicKey) *Finding { switch pub.PubKeyAlgo { case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSAEncryptOnly, packet.PubKeyAlgoRSASignOnly: bits := publicKeyBits(pub) if bits == 0 { return nil } if bits < 2048 { return &Finding{ Code: CodePGPWeakKeySize, Severity: SeverityCrit, Message: fmt.Sprintf("RSA key of %d bits is considered broken. NIST SP 800-131A deprecates anything below 2048 bits.", bits), Fix: "Generate a fresh RSA-3072/4096 or Ed25519/Curve25519 key and republish.", } } if bits < 3072 { return &Finding{ Code: CodePGPWeakKeySize, Severity: SeverityWarn, Message: fmt.Sprintf("RSA-%d is aging; NIST recommends at least 3072 bits for new deployments.", bits), Fix: "Plan a migration to RSA-3072/4096 or Ed25519/Curve25519 at the next key rotation.", } } case packet.PubKeyAlgoDSA, packet.PubKeyAlgoElGamal: return &Finding{ Code: CodePGPWeakAlgorithm, Severity: SeverityWarn, Message: fmt.Sprintf("Primary/subkey uses %s, which modern OpenPGP stacks are phasing out.", algorithmName(pub)), Fix: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.", } } return nil } // ── SMIMEA analysis ────────────────────────────────────────────────────────── // analyzeSMIMEA parses the SMIMEA certificate, computes a structured // summary, and emits findings. func analyzeSMIMEA(body serviceBody, findings []Finding, expiryWarn time.Duration, requireEmailProtection bool) (*SMIMEAInfo, []Finding) { if body.SMIMEA == nil { findings = append(findings, Finding{ Code: CodeSMIMEACertParseError, Severity: SeverityCrit, Message: "Service body has no SMIMEA record.", Fix: "Attach a valid SMIMEA record to the service.", }) return nil, findings } rec := body.SMIMEA info := &SMIMEAInfo{ Usage: rec.Usage, Selector: rec.Selector, MatchingType: rec.MatchingType, HashHex: strings.ToLower(rec.Certificate), } // Usage (RFC 6698 + 8162): 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE. if rec.Usage > 3 { findings = append(findings, Finding{ Code: CodeSMIMEABadUsage, Severity: SeverityCrit, Message: fmt.Sprintf("Unknown SMIMEA usage %d (expected 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE).", rec.Usage), Fix: "Use usage 3 (DANE-EE) for self-hosted S/MIME certificates.", }) } if rec.Selector > 1 { findings = append(findings, Finding{ Code: CodeSMIMEABadSelector, Severity: SeverityCrit, Message: fmt.Sprintf("Unknown SMIMEA selector %d (expected 0 Cert or 1 SPKI).", rec.Selector), Fix: "Use selector 0 to publish the full certificate.", }) } if rec.MatchingType > 2 { findings = append(findings, Finding{ Code: CodeSMIMEABadMatchType, Severity: SeverityCrit, Message: fmt.Sprintf("Unknown SMIMEA matching type %d (expected 0 Full, 1 SHA-256, 2 SHA-512).", rec.MatchingType), Fix: "Use matching type 0 so the whole certificate is transported, or type 1 (SHA-256) for a digest.", }) } // Matching types 1 and 2 only carry a digest; no certificate to // parse. Surface that as info so the user knows the checker's // findings are limited. if rec.MatchingType != 0 { findings = append(findings, Finding{ Code: CodeSMIMEAHashOnly, Severity: SeverityInfo, Message: "Record carries only a digest; the certificate itself cannot be verified by this checker.", Fix: "Switch to matching type 0 (Full) to let verifiers inspect and pin the certificate.", }) return info, findings } der, err := hex.DecodeString(rec.Certificate) if err != nil || len(der) == 0 { findings = append(findings, Finding{ Code: CodeSMIMEACertParseError, Severity: SeverityCrit, Message: fmt.Sprintf("Cannot decode certificate bytes: %v", err), Fix: "Re-export the certificate as DER and hex-encode it into the SMIMEA RDATA.", }) return info, findings } // Selector 1 carries only a SubjectPublicKeyInfo; parse it that way. if rec.Selector == 1 { info.PublicKey = analyzeSPKI(der, &findings) return info, findings } cert, err := x509.ParseCertificate(der) if err != nil { // Try a PEM fallback for robustness. if block, _ := pem.Decode(der); block != nil && block.Type == "CERTIFICATE" { cert, err = x509.ParseCertificate(block.Bytes) } } if err != nil || cert == nil { if err == nil { err = fmt.Errorf("no certificate found") } findings = append(findings, Finding{ Code: CodeSMIMEACertParseError, Severity: SeverityCrit, Message: fmt.Sprintf("Cannot parse X.509 certificate: %v", err), Fix: "Ensure the certificate is DER-encoded (not PEM) before hex-encoding it into SMIMEA RDATA.", }) return info, findings } ci := &CertInfo{ Subject: cert.Subject.String(), Issuer: cert.Issuer.String(), SerialHex: strings.ToUpper(hex.EncodeToString(cert.SerialNumber.Bytes())), NotBefore: cert.NotBefore, NotAfter: cert.NotAfter, SignatureAlgorithm: cert.SignatureAlgorithm.String(), PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(), EmailAddresses: cert.EmailAddresses, DNSNames: cert.DNSNames, IsCA: cert.IsCA, } ci.IsSelfSigned = cert.Subject.String() == cert.Issuer.String() && cert.CheckSignatureFrom(cert) == nil ci.PublicKeyBits = x509PublicKeyBits(cert.PublicKey) for _, eku := range cert.ExtKeyUsage { if eku == x509.ExtKeyUsageEmailProtection { ci.HasEmailProtectionEKU = true } } if cert.KeyUsage&x509.KeyUsageDigitalSignature != 0 { ci.HasDigitalSignature = true } if cert.KeyUsage&x509.KeyUsageKeyEncipherment != 0 { ci.HasKeyEncipherment = true } info.Certificate = ci now := time.Now() if now.Before(cert.NotBefore) { findings = append(findings, Finding{ Code: CodeSMIMEACertNotYetValid, Severity: SeverityCrit, Message: fmt.Sprintf("Certificate is not yet valid (NotBefore = %s).", cert.NotBefore.Format(time.RFC3339)), Fix: "Check the system clock on the CA/signer, or wait until the certificate's notBefore date.", }) } if now.After(cert.NotAfter) { findings = append(findings, Finding{ Code: CodeSMIMEACertExpired, Severity: SeverityCrit, Message: fmt.Sprintf("Certificate expired on %s.", cert.NotAfter.Format(time.RFC3339)), Fix: "Issue a fresh certificate and republish the SMIMEA record.", }) } else if expiryWarn > 0 && cert.NotAfter.Sub(now) < expiryWarn { findings = append(findings, Finding{ Code: CodeSMIMEACertExpiringSoon, Severity: SeverityWarn, Message: fmt.Sprintf("Certificate expires on %s.", cert.NotAfter.Format(time.RFC3339)), Fix: "Renew before expiry and update the SMIMEA record with the new certificate.", }) } if !ci.HasEmailProtectionEKU { sev := SeverityWarn if requireEmailProtection { sev = SeverityCrit } findings = append(findings, Finding{ Code: CodeSMIMEANoEmailProtection, Severity: sev, Message: "Certificate lacks the emailProtection Extended Key Usage; RFC 8550/8551 agents will refuse it.", Fix: "Re-issue the certificate with `extendedKeyUsage = emailProtection` (OID 1.3.6.1.5.5.7.3.4).", }) } if !ci.HasDigitalSignature && !ci.HasKeyEncipherment { findings = append(findings, Finding{ Code: CodeSMIMEANoKeyUsage, Severity: SeverityWarn, Message: "Certificate has neither digitalSignature nor keyEncipherment key usage; S/MIME signing or encryption will be refused.", Fix: "Add `keyUsage = digitalSignature, keyEncipherment` to the certificate profile.", }) } // Weak signature algorithms (MD5/SHA-1 based signatures). switch cert.SignatureAlgorithm { case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA, x509.DSAWithSHA1, x509.ECDSAWithSHA1: findings = append(findings, Finding{ Code: CodeSMIMEAWeakSignatureAlg, Severity: SeverityCrit, Message: fmt.Sprintf("Certificate is signed with %s, a deprecated algorithm.", cert.SignatureAlgorithm), Fix: "Re-issue the certificate with SHA-256 (or better) signatures.", }) } // Weak key sizes. if _, isRSA := cert.PublicKey.(*rsa.PublicKey); isRSA && ci.PublicKeyBits > 0 { if ci.PublicKeyBits < 2048 { findings = append(findings, Finding{ Code: CodeSMIMEAWeakKeySize, Severity: SeverityCrit, Message: fmt.Sprintf("RSA key is %d bits (below the 2048-bit minimum).", ci.PublicKeyBits), Fix: "Re-issue the certificate with an RSA-3072 or ECDSA-P256 key.", }) } else if ci.PublicKeyBits < 3072 { findings = append(findings, Finding{ Code: CodeSMIMEAWeakKeySize, Severity: SeverityWarn, Message: fmt.Sprintf("RSA key is %d bits; prefer 3072+ for new deployments.", ci.PublicKeyBits), Fix: "Plan a rotation to RSA-3072+ or ECDSA at the next certificate renewal.", }) } } // Self-signed certificate flagged for EE usages. For TA usages (0/2) // self-signed is expected. if ci.IsSelfSigned && (rec.Usage == 1 || rec.Usage == 3) { findings = append(findings, Finding{ Code: CodeSMIMEASelfSigned, Severity: SeverityInfo, 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.", Fix: "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.", }) } // Email-address / username pairing. if body.Username != "" { wantPrefix := strings.ToLower(body.Username) + "@" matched := false for _, e := range cert.EmailAddresses { if strings.HasPrefix(strings.ToLower(e), wantPrefix) { matched = true break } } if !matched && len(cert.EmailAddresses) > 0 { findings = append(findings, Finding{ Code: CodeSMIMEAEmailMismatch, Severity: SeverityInfo, 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(cert.EmailAddresses, ", "), body.Username+"@"), Fix: "Re-issue the certificate with the correct `subjectAltName = email:@`.", }) } } return info, findings } func analyzeSPKI(der []byte, findings *[]Finding) *PubKeyInfo { pub, err := x509.ParsePKIXPublicKey(der) if err != nil { *findings = append(*findings, Finding{ Code: CodeSMIMEACertParseError, Severity: SeverityCrit, Message: fmt.Sprintf("Cannot parse SubjectPublicKeyInfo: %v", err), Fix: "Ensure the SMIMEA selector=1 record carries a DER-encoded SPKI (not a full certificate).", }) return nil } info := &PubKeyInfo{Bits: x509PublicKeyBits(pub)} switch pub.(type) { case *rsa.PublicKey: info.Algorithm = "RSA" case *ecdsa.PublicKey: info.Algorithm = "ECDSA" case ed25519.PublicKey: info.Algorithm = "Ed25519" default: info.Algorithm = fmt.Sprintf("%T", pub) } return info } func x509PublicKeyBits(pub any) int { switch k := pub.(type) { case *rsa.PublicKey: if k == nil || k.N == nil { return 0 } return k.N.BitLen() case *ecdsa.PublicKey: if k == nil || k.Params() == nil { return 0 } return k.Params().BitSize case ed25519.PublicKey: return 256 } return 0 } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" }