Initial commit
This commit is contained in:
commit
19296f4188
18 changed files with 2562 additions and 0 deletions
848
checker/collect.go
Normal file
848
checker/collect.go
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
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 <hash>._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 ".<prefix>." 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 <fpr> | 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 <fpr>` → `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 <fpr>` → `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 <fpr>` → `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:<user>@<domain>`.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue