Initial commit

This commit is contained in:
nemunaire 2026-04-23 12:13:33 +07:00
commit 6424f920dd
25 changed files with 3737 additions and 0 deletions

566
checker/collect.go Normal file
View file

@ -0,0 +1,566 @@
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"
)
// maxKeyMaterialBytes caps the decoded byte size of an OPENPGPKEY
// payload or an SMIMEA certificate before it is handed to the parser.
// Anything larger is rejected outright to keep parser costs bounded; a
// rule (e.g. RulePGPRecordTooLarge at 4 KiB) flags more conservative
// limits separately. 64 KiB is well above any legitimate OpenPGP key
// size while staying clear of pathological input.
const maxKeyMaterialBytes = 64 * 1024
// 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 DANE-email data gathering pipeline and returns an
// *EmailKeyData carrying raw facts (DNS outcome, parsed key / cert
// structure). Judgment, severity, fix hints, option-driven thresholds,
// is deferred to the rules. 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)
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)
// Owner-name hash inputs: rules compare the two and decide.
if data.Username != "" {
data.ExpectedOwnerPrefix = ownerHashHex(data.Username)
data.ObservedOwnerPrefix = extractOwnerPrefix(data.QueriedOwner, prefix, parent)
}
// 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.DNSQueryError = fmt.Sprintf("DNS lookup for %s %s failed: %v", dns.TypeToString[qtype], data.QueriedOwner, err)
} else {
data.Resolver = ans.Server
secure := ans.AD
data.DNSSECSecure = &secure
data.RecordCount = len(ans.Records)
present := !(ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0)
data.DNSAnswerPresent = &present
// Compare DNS-returned record bytes with the service-declared ones
// only when we actually have records to compare and a reference.
if present {
var match bool
switch {
case kind == KindOpenPGPKey && body.OpenPGP != nil:
match = anyOpenPGPMatches(ans.Records, body.OpenPGP)
data.DNSRecordMatchesService = &match
case kind == KindSMIMEA && body.SMIMEA != nil:
match = anySMIMEAMatches(ans.Records, body.SMIMEA)
data.DNSRecordMatchesService = &match
}
}
}
}
// Parse the payload from the service body (so rules can evaluate even
// when the DNS lookup failed to reach the authoritative servers).
if kind == KindOpenPGPKey {
data.OpenPGP = analyzeOpenPGP(body)
} else {
data.SMIMEA = analyzeSMIMEA(body)
}
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
// returns a structured fact summary. When parsing fails, ParseError is
// populated and the rest of the fields hold whatever could be recovered.
func analyzeOpenPGP(body serviceBody) *OpenPGPInfo {
if body.OpenPGP == nil {
return &OpenPGPInfo{ParseError: "Service body has no OPENPGPKEY record."}
}
encoded := body.OpenPGP.PublicKey
// Reject pathological payloads before allocating: the base64-decoded
// size is at most ceil(len(encoded)*3/4).
if len(encoded)/4*3 > maxKeyMaterialBytes {
return &OpenPGPInfo{
RawSize: len(encoded) / 4 * 3,
ParseError: fmt.Sprintf("OPENPGPKEY payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes),
}
}
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return &OpenPGPInfo{ParseError: fmt.Sprintf("OPENPGPKEY record carries invalid base64: %v", err)}
}
if len(raw) > maxKeyMaterialBytes {
return &OpenPGPInfo{
RawSize: len(raw),
ParseError: fmt.Sprintf("OPENPGPKEY payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes),
}
}
info := &OpenPGPInfo{RawSize: len(raw)}
entities, err := openpgp.ReadKeyRing(bytes.NewReader(raw))
if err != nil || len(entities) == 0 {
if err == nil {
err = fmt.Errorf("no OpenPGP entity found")
}
info.ParseError = fmt.Sprintf("Cannot parse OpenPGP key: %v", err)
return info
}
info.EntityCount = len(entities)
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)
for name := range ent.Identities {
info.UIDs = append(info.UIDs, name)
}
if len(ent.Revocations) > 0 {
info.Revoked = true
}
// 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 {
info.ExpiresAt = pub.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second)
}
}
// UID vs username matching.
if len(ent.Identities) > 0 && 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
}
}
info.MatchesUsername = &matched
}
// 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
}
}
// 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
}
return info
}
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
}
// ── SMIMEA analysis ──────────────────────────────────────────────────────────
// analyzeSMIMEA parses the SMIMEA certificate and returns a structured
// fact summary. When parsing fails, ParseError is populated.
func analyzeSMIMEA(body serviceBody) *SMIMEAInfo {
if body.SMIMEA == nil {
return &SMIMEAInfo{ParseError: "Service body has no SMIMEA record."}
}
rec := body.SMIMEA
info := &SMIMEAInfo{
Usage: rec.Usage,
Selector: rec.Selector,
MatchingType: rec.MatchingType,
HashHex: strings.ToLower(rec.Certificate),
}
// Matching types 1 and 2 only carry a digest; no certificate or SPKI
// to parse. Rules surface that; here we just stop.
if rec.MatchingType != 0 {
return info
}
if len(rec.Certificate)/2 > maxKeyMaterialBytes {
info.ParseError = fmt.Sprintf("SMIMEA payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes)
return info
}
der, err := hex.DecodeString(rec.Certificate)
if err != nil || len(der) == 0 {
info.ParseError = fmt.Sprintf("Cannot decode certificate bytes: %v", err)
return info
}
if len(der) > maxKeyMaterialBytes {
info.ParseError = fmt.Sprintf("SMIMEA payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes)
return info
}
// Selector 1 carries only a SubjectPublicKeyInfo; parse it that way.
if rec.Selector == 1 {
info.PublicKey = analyzeSPKI(der, info)
return info
}
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")
}
info.ParseError = fmt.Sprintf("Cannot parse X.509 certificate: %v", err)
return info
}
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
}
// Email-address / username pairing fact.
if body.Username != "" && len(cert.EmailAddresses) > 0 {
wantPrefix := strings.ToLower(body.Username) + "@"
matched := false
for _, e := range cert.EmailAddresses {
if strings.HasPrefix(strings.ToLower(e), wantPrefix) {
matched = true
break
}
}
ci.EmailMatchesUsername = &matched
}
info.Certificate = ci
return info
}
func analyzeSPKI(der []byte, info *SMIMEAInfo) *PubKeyInfo {
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
info.ParseError = fmt.Sprintf("Cannot parse SubjectPublicKeyInfo: %v", err)
return nil
}
pk := &PubKeyInfo{Bits: x509PublicKeyBits(pub)}
switch pub.(type) {
case *rsa.PublicKey:
pk.Algorithm = "RSA"
case *ecdsa.PublicKey:
pk.Algorithm = "ECDSA"
case ed25519.PublicKey:
pk.Algorithm = "Ed25519"
default:
pk.Algorithm = fmt.Sprintf("%T", pub)
}
return pk
}
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 ""
}

219
checker/collect_test.go Normal file
View file

@ -0,0 +1,219 @@
package checker
import (
"encoding/base64"
"encoding/hex"
"strings"
"testing"
"github.com/miekg/dns"
)
func TestOwnerHashHex(t *testing.T) {
// RFC 7929 worked example: the SHA-256 of "hugh" truncated to 28
// bytes, hex-encoded.
got := ownerHashHex("hugh")
if len(got) != 56 {
t.Fatalf("len = %d, want 56", len(got))
}
if got != strings.ToLower(got) {
t.Errorf("expected lowercase hex, got %q", got)
}
// Stable across calls.
if ownerHashHex("hugh") != got {
t.Error("ownerHashHex is not deterministic")
}
// Different inputs ⇒ different output.
if ownerHashHex("alice") == got {
t.Error("collisions across distinct inputs")
}
}
func TestExtractOwnerPrefix(t *testing.T) {
cases := []struct {
owner, prefix, want string
}{
{"abc123._openpgpkey.example.com.", "_openpgpkey", "abc123"},
{"ABC123._OPENPGPKEY.example.com", "_openpgpkey", "abc123"},
{"abc123._smimecert.example.com.", "_smimecert", "abc123"},
{"example.com.", "_openpgpkey", ""},
{"_openpgpkey.example.com.", "_openpgpkey", ""}, // no leading hash label
{"", "_openpgpkey", ""},
}
for _, c := range cases {
got := extractOwnerPrefix(c.owner, c.prefix, "")
if got != c.want {
t.Errorf("extractOwnerPrefix(%q,%q) = %q, want %q", c.owner, c.prefix, got, c.want)
}
}
}
func TestFirstNonEmpty(t *testing.T) {
if got := firstNonEmpty("", " ", "x", "y"); got != "x" {
t.Errorf("got %q, want x", got)
}
if got := firstNonEmpty("", "", ""); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestKindForServiceType(t *testing.T) {
cases := map[string]string{
ServiceOpenPGP: KindOpenPGPKey,
ServiceSMimeCert: KindSMIMEA,
"abstract.Other": "",
"": "",
}
for in, want := range cases {
if got := kindForServiceType(in); got != want {
t.Errorf("kindForServiceType(%q) = %q, want %q", in, got, want)
}
}
}
func TestComputeOwner(t *testing.T) {
body := serviceBody{Username: "alice"}
exp, rec := computeOwner(body, OpenPGPKeyPrefix, "example.com")
wantPrefix := ownerHashHex("alice") + "._openpgpkey.example.com."
if exp != wantPrefix {
t.Errorf("expected = %q, want %q", exp, wantPrefix)
}
if rec != "" {
t.Errorf("recorded = %q, want empty", rec)
}
// With a record carrying its own owner.
body.OpenPGP = &dns.OPENPGPKEY{Hdr: dns.RR_Header{Name: "abc._openpgpkey.example.com."}}
_, rec = computeOwner(body, OpenPGPKeyPrefix, "example.com")
if rec != "abc._openpgpkey.example.com." {
t.Errorf("recorded = %q", rec)
}
// Empty username yields empty expected owner.
exp, _ = computeOwner(serviceBody{}, OpenPGPKeyPrefix, "example.com")
if exp != "" {
t.Errorf("expected = %q, want empty", exp)
}
}
func TestAnyOpenPGPMatches(t *testing.T) {
ref := &dns.OPENPGPKEY{PublicKey: "AAAA"}
rrs := []dns.RR{
&dns.OPENPGPKEY{PublicKey: "BBBB"},
&dns.OPENPGPKEY{PublicKey: " AAAA "}, // trims whitespace
}
if !anyOpenPGPMatches(rrs, ref) {
t.Error("expected match")
}
if anyOpenPGPMatches([]dns.RR{&dns.OPENPGPKEY{PublicKey: "ZZZZ"}}, ref) {
t.Error("unexpected match")
}
// Non-OPENPGPKEY RRs are skipped silently.
if anyOpenPGPMatches([]dns.RR{&dns.A{}}, ref) {
t.Error("non-OPENPGPKEY RR matched")
}
}
func TestAnySMIMEAMatches(t *testing.T) {
ref := &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "DEADBEEF"}
rrs := []dns.RR{
&dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "deadbeef"},
}
if !anySMIMEAMatches(rrs, ref) {
t.Error("expected case-insensitive match")
}
rrs = []dns.RR{&dns.SMIMEA{Usage: 1, Selector: 0, MatchingType: 0, Certificate: "deadbeef"}}
if anySMIMEAMatches(rrs, ref) {
t.Error("usage mismatch should not match")
}
}
func TestAnalyzeOpenPGP_NoRecord(t *testing.T) {
got := analyzeOpenPGP(serviceBody{})
if got == nil || got.ParseError == "" {
t.Fatalf("expected ParseError, got %+v", got)
}
}
func TestAnalyzeOpenPGP_BadBase64(t *testing.T) {
body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: "!!! not base64 !!!"}}
got := analyzeOpenPGP(body)
if !strings.Contains(got.ParseError, "invalid base64") {
t.Errorf("ParseError = %q", got.ParseError)
}
}
func TestAnalyzeOpenPGP_OversizePayload(t *testing.T) {
// A base64 payload whose decoded size would exceed the cap.
raw := make([]byte, maxKeyMaterialBytes+1024)
body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: base64.StdEncoding.EncodeToString(raw)}}
got := analyzeOpenPGP(body)
if !strings.Contains(got.ParseError, "exceeds") {
t.Errorf("expected size-limit ParseError, got %q", got.ParseError)
}
// And we never tried to actually parse it as a keyring.
if got.EntityCount != 0 {
t.Errorf("EntityCount = %d, want 0", got.EntityCount)
}
}
func TestAnalyzeOpenPGP_GarbageBytes(t *testing.T) {
// Valid base64, but not a valid OpenPGP packet stream.
body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: base64.StdEncoding.EncodeToString([]byte("not a key"))}}
got := analyzeOpenPGP(body)
if got.ParseError == "" {
t.Error("expected ParseError for garbage payload")
}
if got.RawSize == 0 {
t.Error("RawSize should be set even on parse failure")
}
}
func TestAnalyzeSMIMEA_NoRecord(t *testing.T) {
got := analyzeSMIMEA(serviceBody{})
if got == nil || got.ParseError == "" {
t.Fatalf("expected ParseError, got %+v", got)
}
}
func TestAnalyzeSMIMEA_DigestOnly(t *testing.T) {
body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 1, Certificate: "abcd"}}
got := analyzeSMIMEA(body)
if got.ParseError != "" {
t.Errorf("digest-only should not error: %q", got.ParseError)
}
if got.Certificate != nil || got.PublicKey != nil {
t.Error("digest-only should not populate Certificate/PublicKey")
}
if got.HashHex != "abcd" {
t.Errorf("HashHex = %q", got.HashHex)
}
}
func TestAnalyzeSMIMEA_BadHex(t *testing.T) {
body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "ZZZZ"}}
got := analyzeSMIMEA(body)
if got.ParseError == "" {
t.Error("expected ParseError for invalid hex")
}
}
func TestAnalyzeSMIMEA_OversizePayload(t *testing.T) {
huge := strings.Repeat("ab", maxKeyMaterialBytes+1024)
body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: huge}}
got := analyzeSMIMEA(body)
if !strings.Contains(got.ParseError, "exceeds") {
t.Errorf("expected size-limit ParseError, got %q", got.ParseError)
}
}
func TestAnalyzeSMIMEA_NotACertificate(t *testing.T) {
body := serviceBody{SMIMEA: &dns.SMIMEA{
Usage: 3, Selector: 0, MatchingType: 0,
Certificate: hex.EncodeToString([]byte("not a DER cert")),
}}
got := analyzeSMIMEA(body)
if got.ParseError == "" {
t.Error("expected ParseError for non-cert bytes")
}
}

80
checker/definition.go Normal file
View file

@ -0,0 +1,80 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version defaults to "built-in"; standalone and plugin builds override
// it via -ldflags "-X .../checker.Version=...".
var Version = "built-in"
// Option ids.
const (
OptionResolver = "resolver"
OptionCertExpiryWarnDays = "certExpiryWarnDays"
OptionRequireDNSSEC = "requireDNSSEC"
OptionRequireEmailProtection = "requireEmailProtection"
)
// Definition is the package-level helper returned to the host by the
// plugin entrypoint and used by server.New via the provider's
// CheckerDefinitionProvider implementation.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "openpgpkey-smimea",
Name: "OPENPGPKEY & SMIMEA",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{
ServiceOpenPGP,
ServiceSMimeCert,
},
},
ObservationKeys: []sdk.ObservationKey{ObservationKey},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionResolver,
Type: "string",
Label: "DNS resolver",
Placeholder: "1.1.1.1",
Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.",
},
},
DomainOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Zone origin",
AutoFill: sdk.AutoFillDomainName,
},
{
Id: "subdomain",
Label: "Subdomain",
AutoFill: sdk.AutoFillSubdomain,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: "service",
Label: "Service",
AutoFill: sdk.AutoFillService,
},
{
Id: "service_type",
Label: "Service type",
AutoFill: sdk.AutoFillServiceType,
Hide: true,
},
},
},
Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 12 * time.Hour,
},
}
}

132
checker/dns.go Normal file
View file

@ -0,0 +1,132 @@
package checker
import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/miekg/dns"
)
// dnsTimeout is the per-query deadline used by every helper here.
const dnsTimeout = 5 * time.Second
// maxAnswerRecords caps how many answer RRs of the requested type are
// retained from a single DNS response. A DANE owner serving more than a
// handful of keys is already abnormal; bounding the count keeps later
// per-record work (parsing, comparison) from blowing up if a zone (or a
// hostile resolver) returns a pathological answer set.
const maxAnswerRecords = 64
// dnsLookupAnswer is the subset of a DNS answer this checker cares about.
type dnsLookupAnswer struct {
// Records are the answer records of the requested type.
Records []dns.RR
// AD reports whether the response header has the Authenticated Data
// flag set, i.e. the validating resolver confirmed DNSSEC.
AD bool
// Rcode is the response code from the answering resolver.
Rcode int
// Server is the address of the resolver that answered.
Server string
}
// resolvers returns the list of resolver addresses to try. If resolverOpt
// is non-empty it is parsed (comma-separated allowed) into a host:port
// list. Otherwise /etc/resolv.conf is read; if that fails we fall back to
// public validating resolvers.
func resolvers(resolverOpt string) []string {
if s := strings.TrimSpace(resolverOpt); s != "" {
var out []string
for part := range strings.SplitSeq(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if !strings.Contains(part, ":") {
part = net.JoinHostPort(part, "53")
}
out = append(out, part)
}
if len(out) > 0 {
return out
}
}
if cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf"); err == nil && cfg != nil && len(cfg.Servers) > 0 {
out := make([]string, 0, len(cfg.Servers))
for _, s := range cfg.Servers {
out = append(out, net.JoinHostPort(s, cfg.Port))
}
return out
}
// Fall back to known validating resolvers.
return []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}
}
// lookup queries qtype at owner against each resolver in order until one
// answers. The first resolver whose answer has a non-error Rcode wins.
// DNSSEC validation is requested via EDNS0 DO=1; the AD flag is read back
// from the response header.
func lookup(ctx context.Context, servers []string, owner string, qtype uint16) (*dnsLookupAnswer, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(owner), qtype)
m.SetEdns0(4096, true)
m.RecursionDesired = true
m.AuthenticatedData = true
c := &dns.Client{Timeout: dnsTimeout}
var lastErr error
for _, srv := range servers {
in, _, err := c.ExchangeContext(ctx, m, srv)
if err != nil {
lastErr = err
continue
}
if in == nil {
lastErr = fmt.Errorf("nil response from %s", srv)
continue
}
ans := &dnsLookupAnswer{
Rcode: in.Rcode,
AD: in.AuthenticatedData,
Server: srv,
}
for _, rr := range in.Answer {
if rr.Header().Rrtype != qtype {
continue
}
if len(ans.Records) >= maxAnswerRecords {
break
}
ans.Records = append(ans.Records, rr)
}
if in.Rcode == dns.RcodeSuccess || in.Rcode == dns.RcodeNameError {
return ans, nil
}
lastErr = fmt.Errorf("rcode %s from %s", dns.RcodeToString[in.Rcode], srv)
}
if lastErr == nil {
lastErr = fmt.Errorf("no resolver available")
}
return nil, lastErr
}
// joinSubdomain composes the FQDN of a subdomain within a zone. Both
// arguments are accepted in any of their canonical forms (trailing dot
// optional, empty subdomain allowed).
func joinSubdomain(subdomain, origin string) string {
origin = strings.TrimSuffix(origin, ".")
subdomain = strings.TrimSuffix(subdomain, ".")
if subdomain == "" || subdomain == "@" {
return dns.Fqdn(origin)
}
if strings.HasSuffix(subdomain, "."+origin) || subdomain == origin {
return dns.Fqdn(subdomain)
}
return dns.Fqdn(subdomain + "." + origin)
}

50
checker/dns_test.go Normal file
View file

@ -0,0 +1,50 @@
package checker
import (
"reflect"
"testing"
)
func TestJoinSubdomain(t *testing.T) {
cases := []struct {
sub, origin, want string
}{
{"", "example.com", "example.com."},
{"@", "example.com", "example.com."},
{"www", "example.com", "www.example.com."},
{"www.", "example.com.", "www.example.com."},
{"www.example.com", "example.com", "www.example.com."},
{"example.com", "example.com", "example.com."},
}
for _, c := range cases {
got := joinSubdomain(c.sub, c.origin)
if got != c.want {
t.Errorf("joinSubdomain(%q,%q) = %q, want %q", c.sub, c.origin, got, c.want)
}
}
}
func TestResolvers_Explicit(t *testing.T) {
got := resolvers("1.1.1.1, 8.8.8.8:5353 ,")
want := []string{"1.1.1.1:53", "8.8.8.8:5353"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestResolvers_FallbackList(t *testing.T) {
// We don't trust /etc/resolv.conf to be absent in all CI environments,
// but the empty-input path must always return at least one resolver.
got := resolvers("")
if len(got) == 0 {
t.Fatal("expected at least one resolver")
}
}
func TestMaxAnswerRecords_Constant(t *testing.T) {
// Sanity check: don't silently lower the cap to something useless
// without updating tests / behaviour.
if maxAnswerRecords < 8 {
t.Errorf("maxAnswerRecords=%d is suspiciously low", maxAnswerRecords)
}
}

175
checker/interactive.go Normal file
View file

@ -0,0 +1,175 @@
//go:build standalone
package checker
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
// RenderForm implements server.Interactive. It exposes the minimal
// inputs needed to bootstrap a standalone OPENPGPKEY/SMIMEA check: an
// email address (the local part is hashed into the owner name) and a
// kind selector. The DNS resolver and severity-tuning options mirror
// the regular UserOpts so a human can override them on the form.
func (p *emailKeyProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "email",
Type: "string",
Label: "Email address",
Placeholder: "alice@example.com",
Description: "Address to look up. The local part is SHA-256-hashed per RFC 7929/8162; the domain part is the zone queried.",
Required: true,
},
{
Id: "kind",
Type: "string",
Label: "Record kind",
Default: KindOpenPGPKey,
Choices: []string{KindOpenPGPKey, KindSMIMEA},
},
{
Id: OptionResolver,
Type: "string",
Label: "DNS resolver",
Placeholder: "1.1.1.1",
Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.",
},
{
Id: OptionCertExpiryWarnDays,
Type: "number",
Label: "Expiry warning threshold (days)",
Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.",
Default: float64(30),
},
{
Id: OptionRequireDNSSEC,
Type: "bool",
Label: "Require DNSSEC",
Default: true,
},
{
Id: OptionRequireEmailProtection,
Type: "bool",
Label: "Require emailProtection EKU (SMIMEA only)",
Default: true,
},
}
}
// ParseForm implements server.Interactive. It validates the inputs,
// resolves the DNS record matching the requested kind, and returns the
// CheckerOptions that Collect expects, including a synthesised service
// envelope built from the live DNS answer.
func (p *emailKeyProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
return nil, fmt.Errorf("email is required")
}
at := strings.LastIndex(email, "@")
if at <= 0 || at == len(email)-1 {
return nil, fmt.Errorf("email %q must be of the form local@domain", email)
}
username := email[:at]
domain := strings.TrimSuffix(strings.ToLower(email[at+1:]), ".")
kind := strings.TrimSpace(r.FormValue("kind"))
if kind == "" {
kind = KindOpenPGPKey
}
var (
svcType string
prefix string
qtype uint16
)
switch kind {
case KindOpenPGPKey:
svcType = ServiceOpenPGP
prefix = OpenPGPKeyPrefix
qtype = dns.TypeOPENPGPKEY
case KindSMIMEA:
svcType = ServiceSMimeCert
prefix = SMIMEACertPrefix
qtype = dns.TypeSMIMEA
default:
return nil, fmt.Errorf("unknown kind %q (expected %q or %q)", kind, KindOpenPGPKey, KindSMIMEA)
}
resolverOpt := strings.TrimSpace(r.FormValue(OptionResolver))
owner := dns.Fqdn(ownerHashHex(username) + "." + prefix + "." + domain)
ctx, cancel := context.WithTimeout(r.Context(), dnsTimeout*3)
defer cancel()
ans, err := lookup(ctx, resolvers(resolverOpt), owner, qtype)
if err != nil {
return nil, fmt.Errorf("DNS lookup for %s %s failed: %w", dns.TypeToString[qtype], owner, err)
}
if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 {
return nil, fmt.Errorf("no %s record found at %s", dns.TypeToString[qtype], owner)
}
body := serviceBody{Username: username}
switch kind {
case KindOpenPGPKey:
rr, ok := ans.Records[0].(*dns.OPENPGPKEY)
if !ok {
return nil, fmt.Errorf("unexpected record type %T at %s", ans.Records[0], owner)
}
body.OpenPGP = rr
case KindSMIMEA:
rr, ok := ans.Records[0].(*dns.SMIMEA)
if !ok {
return nil, fmt.Errorf("unexpected record type %T at %s", ans.Records[0], owner)
}
body.SMIMEA = rr
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("encode service body: %w", err)
}
svcMsg := serviceMessage{
Type: svcType,
Domain: dns.Fqdn(domain),
Service: bodyJSON,
}
opts := sdk.CheckerOptions{
"service": svcMsg,
"service_type": svcType,
"domain_name": domain,
}
if resolverOpt != "" {
opts[OptionResolver] = resolverOpt
}
if v := strings.TrimSpace(r.FormValue(OptionCertExpiryWarnDays)); v != "" {
opts[OptionCertExpiryWarnDays] = parseFloatOr(v, 30)
}
opts[OptionRequireDNSSEC] = r.FormValue(OptionRequireDNSSEC) == "true"
opts[OptionRequireEmailProtection] = r.FormValue(OptionRequireEmailProtection) == "true"
return opts, nil
}
// parseFloatOr parses a decimal string, returning fallback on error.
func parseFloatOr(s string, fallback float64) float64 {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return fallback
}
return f
}
// Compile-time assertion that the provider implements the optional interface.
var _ server.Interactive = (*emailKeyProvider)(nil)

23
checker/provider.go Normal file
View file

@ -0,0 +1,23 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new OPENPGPKEY/SMIMEA observation provider.
func Provider() sdk.ObservationProvider {
return &emailKeyProvider{}
}
type emailKeyProvider struct{}
// Key implements sdk.ObservationProvider.
func (p *emailKeyProvider) Key() sdk.ObservationKey {
return ObservationKey
}
// Definition implements sdk.CheckerDefinitionProvider so the SDK server
// can expose /definition without an extra argument.
func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

719
checker/report.go Normal file
View file

@ -0,0 +1,719 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// remediation is a single actionable hint shown in the report's
// "most common issues, fix these first" banner. Bodies are rendered
// with template.HTML so each remediation can ship its own markup
// (pre-formatted code snippets, lists, links).
type remediation struct {
Title string
Body template.HTML
}
// findingRow models a single row in the full findings table.
type findingRow struct {
Code string
Severity string
Message string
Fix string
}
// subkeyRow mirrors SubkeyInfo for the template, with pre-formatted
// times and a Capabilities string.
type subkeyRow struct {
Algorithm string
Bits int
Capabilities string
Created string
Expires string
Revoked bool
}
// reportData is the template context.
type reportData struct {
Kind string
Headline string
Badge string // "ok" / "warn" / "fail" / "neutral"
QueriedOwner string
ExpectedOwner string
Resolver string
DNSSEC string // "secure" / "insecure" / "unknown"
RecordCount int
Username string
CollectedAt string
OpenPGP *openPGPView
SMIMEA *smimeaView
Remediations []remediation
Findings []findingRow
HasStates bool // true when rule states were threaded; gates the Findings section
CritCount int
WarnCount int
InfoCount int
}
type openPGPView struct {
Fingerprint string
KeyID string
Algorithm string
Bits int
UIDs []string
Created string
Expires string
Revoked bool
Encrypt bool
Subkeys []subkeyRow
RawSize int
EntityCount int
}
type smimeaView struct {
Usage string
Selector string
MatchingType string
HashOnly bool
HashHex string
Subject string
Issuer string
Serial string
NotBefore string
NotAfter string
SignatureAlgo string
KeyAlgo string
Bits int
Emails []string
DNSNames []string
EmailProtection bool
DigitalSignature bool
KeyEncipherment bool
SelfSigned bool
IsCA bool
}
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *emailKeyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data EmailKeyData
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
return "", fmt.Errorf("unmarshal report data: %w", err)
}
rd := buildReportData(&data, ctx.States())
var buf strings.Builder
if err := reportTemplate.Execute(&buf, rd); err != nil {
return "", fmt.Errorf("render report: %w", err)
}
return buf.String(), nil
}
func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData {
rd := reportData{
Kind: d.Kind,
QueriedOwner: d.QueriedOwner,
ExpectedOwner: d.ExpectedOwner,
Resolver: d.Resolver,
RecordCount: d.RecordCount,
Username: d.Username,
CollectedAt: d.CollectedAt.UTC().Format(time.RFC3339),
}
switch {
case d.DNSSECSecure == nil:
rd.DNSSEC = "unknown"
case *d.DNSSECSecure:
rd.DNSSEC = "secure"
default:
rd.DNSSEC = "insecure"
}
if d.Kind == KindOpenPGPKey && d.OpenPGP != nil {
rd.OpenPGP = buildOpenPGPView(d.OpenPGP)
}
if d.Kind == KindSMIMEA && d.SMIMEA != nil {
rd.SMIMEA = buildSMIMEAView(d.SMIMEA)
}
// No rule states threaded through: data-only view.
if len(states) == 0 {
rd.Badge = "neutral"
rd.Headline = "Record details"
return rd
}
rd.HasStates = true
// Pick the states we want on screen: drop bare StatusOK, and drop
// StatusInfo with no message (non-applicable rules). Keep anything
// else.
kept := make([]sdk.CheckState, 0, len(states))
for _, s := range states {
if s.Status == sdk.StatusOK {
continue
}
if s.Status == sdk.StatusInfo && strings.TrimSpace(s.Message) == "" {
continue
}
kept = append(kept, s)
}
// Sort by severity (crit first).
sort.SliceStable(kept, func(i, j int) bool {
return statusRank(kept[i].Status) > statusRank(kept[j].Status)
})
for _, s := range kept {
rd.Findings = append(rd.Findings, findingRow{
Code: s.Code,
Severity: severityLabel(s.Status),
Message: s.Message,
Fix: stateHint(s),
})
switch s.Status {
case sdk.StatusCrit, sdk.StatusError:
rd.CritCount++
case sdk.StatusWarn:
rd.WarnCount++
case sdk.StatusInfo:
rd.InfoCount++
}
}
switch {
case rd.CritCount > 0:
rd.Badge = "fail"
rd.Headline = fmt.Sprintf("%d critical issue(s) found", rd.CritCount)
case rd.WarnCount > 0:
rd.Badge = "warn"
rd.Headline = fmt.Sprintf("%d warning(s)", rd.WarnCount)
case rd.InfoCount > 0:
rd.Badge = "neutral"
rd.Headline = "Informational findings"
default:
rd.Badge = "ok"
rd.Headline = "All checks passed"
}
rd.Remediations = buildRemediations(d, kept)
return rd
}
func stateHint(s sdk.CheckState) string {
if s.Meta == nil {
return ""
}
if v, ok := s.Meta["hint"].(string); ok {
return v
}
return ""
}
func severityLabel(st sdk.Status) string {
switch st {
case sdk.StatusCrit, sdk.StatusError:
return "crit"
case sdk.StatusWarn:
return "warn"
case sdk.StatusInfo:
return "info"
}
return "info"
}
func statusRank(st sdk.Status) int {
switch st {
case sdk.StatusCrit, sdk.StatusError:
return 3
case sdk.StatusWarn:
return 2
case sdk.StatusInfo:
return 1
}
return 0
}
func buildOpenPGPView(o *OpenPGPInfo) *openPGPView {
v := &openPGPView{
Fingerprint: formatFingerprint(o.Fingerprint),
KeyID: o.KeyID,
Algorithm: o.PrimaryAlgorithm,
Bits: o.PrimaryBits,
UIDs: append([]string(nil), o.UIDs...),
Created: fmtTime(o.CreatedAt),
Expires: fmtTime(o.ExpiresAt),
Revoked: o.Revoked,
Encrypt: o.HasEncryptionCapability,
RawSize: o.RawSize,
EntityCount: o.EntityCount,
}
if v.Expires == "" {
v.Expires = "never"
}
sort.Strings(v.UIDs)
for _, sk := range o.Subkeys {
caps := subkeyCaps(sk)
v.Subkeys = append(v.Subkeys, subkeyRow{
Algorithm: sk.Algorithm,
Bits: sk.Bits,
Capabilities: caps,
Created: fmtTime(sk.CreatedAt),
Expires: fmtTimeOrNever(sk.ExpiresAt),
Revoked: sk.Revoked,
})
}
return v
}
func buildSMIMEAView(s *SMIMEAInfo) *smimeaView {
v := &smimeaView{
Usage: smimeaUsageName(s.Usage),
Selector: smimeaSelectorName(s.Selector),
MatchingType: smimeaMatchingTypeName(s.MatchingType),
HashOnly: s.MatchingType != 0,
HashHex: s.HashHex,
}
if s.Certificate != nil {
c := s.Certificate
v.Subject = c.Subject
v.Issuer = c.Issuer
v.Serial = c.SerialHex
v.NotBefore = fmtTime(c.NotBefore)
v.NotAfter = fmtTime(c.NotAfter)
v.SignatureAlgo = c.SignatureAlgorithm
v.KeyAlgo = c.PublicKeyAlgorithm
v.Bits = c.PublicKeyBits
v.Emails = append([]string(nil), c.EmailAddresses...)
v.DNSNames = append([]string(nil), c.DNSNames...)
v.EmailProtection = c.HasEmailProtectionEKU
v.DigitalSignature = c.HasDigitalSignature
v.KeyEncipherment = c.HasKeyEncipherment
v.SelfSigned = c.IsSelfSigned
v.IsCA = c.IsCA
}
if s.PublicKey != nil && v.KeyAlgo == "" {
v.KeyAlgo = s.PublicKey.Algorithm
v.Bits = s.PublicKey.Bits
}
return v
}
// buildRemediations surfaces a focused, user-actionable card for each
// of the most common failure scenarios present in `states`. Only rules
// with a matching state produce a remediation; a clean run shows none.
func buildRemediations(d *EmailKeyData, states []sdk.CheckState) []remediation {
var out []remediation
byCode := map[string]bool{}
for _, s := range states {
byCode[s.Code] = true
}
pick := func(code, title, body string) {
if !byCode[code] {
return
}
out = append(out, remediation{Title: title, Body: template.HTML(body)})
}
pick(RuleDNSNoRecord,
"Publish the record in DNS",
fmt.Sprintf(`No <code>%s</code> record resolves at <code>%s</code>. Publish it in the zone and reload the authoritative servers.<br><br>
Quick checklist:
<ol>
<li>Verify the owner name: <code>sha256(localpart)[0:28] . %s . %s</code>.</li>
<li>Confirm the record reached your signer by running <code>dig +dnssec %s %s @&lt;auth-ns&gt;</code>.</li>
<li>Wait for TTL expiry if the record was only recently published.</li>
</ol>`,
kindRRType(d.Kind),
template.HTMLEscapeString(d.QueriedOwner),
template.HTMLEscapeString(kindPrefix(d.Kind)),
template.HTMLEscapeString(strings.TrimSuffix(d.Domain, ".")),
kindRRType(d.Kind),
template.HTMLEscapeString(d.QueriedOwner)))
pick(RuleDNSSECNotValidated,
"Enable DNSSEC on the zone",
`RFC 7929 and RFC 8162 only grant authority to the key/certificate when DNSSEC validates it. Without DNSSEC, an attacker on the network path can substitute the RR with their own material and impersonate the user.<br><br>
Steps:
<ol>
<li>Sign the zone (Bind: <code>dnssec-policy default</code>; Knot: <code>dnssec-signing: on</code>; BIND/Knot-DNSSEC-policy or NSD+OpenDNSSEC, etc.).</li>
<li>Publish the DS record at the parent via your registrar.</li>
<li>Re-run this checker; the AD flag should light up.</li>
</ol>`)
pick(RuleOwnerHashMismatch,
"Fix the record's owner-name hash",
`The record is published at a name whose first label does not equal <code>hex(sha256(localpart))[:56]</code> (28 bytes). Email agents will never find it because they compute the hash from the recipient address.<br><br>
Compute the correct name:<br>
<pre>printf '%s' "<em>local-part</em>" | openssl dgst -sha256 | cut -c 1-56 | tr -d '\n' ; echo ".<em>_openpgpkey</em>.<em>domain.tld</em>"</pre>
Then republish the record at that owner name.`)
pick(RulePGPPrimaryExpired,
"Renew the expired OpenPGP key",
`The primary key's self-signature expired, so clients will refuse to encrypt to it.<br>
<pre>gpg --edit-key &lt;fingerprint&gt;
gpg&gt; expire
... set a new expiration ...
gpg&gt; save
gpg --export &lt;fingerprint&gt; | base64</pre>
Paste the resulting base64 back into the OPENPGPKEY record.`)
pick(RulePGPPrimaryRevoked,
"Publish a fresh, non-revoked key",
`The record carries a revoked primary key; clients will stop encrypting mail to this address as soon as they process the revocation.<br><br>
Either generate a new key pair and publish it here, or remove the OPENPGPKEY record so senders fall back to regular email (unencrypted).`)
pick(RulePGPNoEncryption,
"Add an encryption subkey",
`Every non-revoked key in the record is marked sign-only. Mail clients will refuse to encrypt to this record.<br>
<pre>gpg --edit-key &lt;fingerprint&gt;
gpg&gt; addkey
... choose "RSA (encrypt only)" or "ECC (encrypt only)" ...
gpg&gt; save</pre>
Re-export and republish.`)
pick(RulePGPWeakKeySize,
"Rotate away from weak RSA keys",
`RSA below 2048 bits is considered broken. Generate a modern key and republish:<br>
<pre>gpg --full-generate-key
# choose 1 (RSA+RSA) with 3072/4096 bits,
# or 9 (ECC+ECC) for Curve25519.</pre>`)
pick(RuleSMIMEACertExpired,
"Renew the S/MIME certificate",
`The certificate expired. Issue a fresh one and update the SMIMEA record:<br>
<pre>openssl req -new -key user.key -subj "/emailAddress=user@example.org" -out user.csr
... obtain a signed cert from your S/MIME CA ...
openssl x509 -in user.crt -outform DER | xxd -p -c256 &gt; smimea.hex</pre>
Splice the hex payload into the SMIMEA RDATA.`)
pick(RuleSMIMEANoEmailProtect,
"Add the emailProtection EKU",
`Conforming S/MIME agents (RFC 8550/8551) only accept certificates whose Extended Key Usage advertises email protection (OID 1.3.6.1.5.5.7.3.4).<br><br>
In your <code>openssl.cnf</code>:<br>
<pre>[usr_cert]
extendedKeyUsage = emailProtection
keyUsage = digitalSignature, keyEncipherment</pre>
Re-issue the certificate, then update the SMIMEA record.`)
pick(RuleSMIMEAWeakSigAlgorithm,
"Re-issue with a strong signature algorithm",
`MD5 and SHA-1 based signatures are collision-vulnerable and will be rejected by modern mail agents.<br><br>
Use at least SHA-256 when issuing:<br>
<pre>openssl x509 -req -sha256 -in user.csr -CA ca.pem -CAkey ca.key -out user.crt</pre>`)
pick(RuleSMIMEABadUsage,
"Pick a valid SMIMEA usage",
`SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA) or 3 (DANE-EE). For self-hosted end-entity certificates, <strong>3 (DANE-EE)</strong> is the right choice: it tells verifiers the record carries the exact certificate to trust and no chain validation is required.`)
pick(RuleSMIMEAHashOnly,
"Consider publishing the full certificate",
`Matching types 1 (SHA-256) and 2 (SHA-512) only transport a digest. Consumers cannot extract the certificate from DNS and must obtain it through a side channel. Matching type 0 (Full) avoids that round trip and is the most interoperable option.`)
return out
}
func smimeaUsageName(u uint8) string {
switch u {
case 0:
return "0 PKIX-TA"
case 1:
return "1 PKIX-EE"
case 2:
return "2 DANE-TA"
case 3:
return "3 DANE-EE"
}
return fmt.Sprintf("%d (unknown)", u)
}
func smimeaSelectorName(s uint8) string {
switch s {
case 0:
return "0 Cert"
case 1:
return "1 SPKI"
}
return fmt.Sprintf("%d (unknown)", s)
}
func smimeaMatchingTypeName(m uint8) string {
switch m {
case 0:
return "0 Full"
case 1:
return "1 SHA-256"
case 2:
return "2 SHA-512"
}
return fmt.Sprintf("%d (unknown)", m)
}
func kindRRType(k string) string {
if k == KindSMIMEA {
return "SMIMEA"
}
return "OPENPGPKEY"
}
func kindPrefix(k string) string {
if k == KindSMIMEA {
return "_smimecert"
}
return "_openpgpkey"
}
func subkeyCaps(sk SubkeyInfo) string {
var caps []string
if sk.CanSign {
caps = append(caps, "sign")
}
if sk.CanEncrypt {
caps = append(caps, "encrypt")
}
if sk.CanAuth {
caps = append(caps, "auth")
}
if len(caps) == 0 {
return "-"
}
return strings.Join(caps, ", ")
}
func fmtTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func fmtTimeOrNever(t time.Time) string {
s := fmtTime(t)
if s == "" {
return "never"
}
return s
}
func formatFingerprint(fp string) string {
if fp == "" {
return ""
}
fp = strings.ToUpper(fp)
var b strings.Builder
for i, r := range fp {
if i > 0 && i%4 == 0 {
b.WriteByte(' ')
}
b.WriteRune(r)
}
return b.String()
}
var reportTemplate = template.Must(template.New("openpgpkey").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OPENPGPKEY / SMIMEA report</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px; line-height: 1.5; color: #1f2937; background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
pre {
font-family: ui-monospace, monospace; font-size: .82em;
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px;
padding: .55rem .7rem; overflow-x: auto; margin: .35rem 0 0;
}
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd {
background: #fff; border-radius: 10px;
padding: 1rem 1.25rem; margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.neutral { background: #e5e7eb; color: #374151; }
.info { background: #dbeafe; color: #1e40af; }
.sub { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
.sub code { color: #111827; }
.section {
background: #fff; border-radius: 8px;
padding: .85rem 1rem; margin-bottom: .6rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
}
.reme {
background: #fff7ed; border: 1px solid #fdba74; border-left: 4px solid #f97316;
border-radius: 8px; padding: .75rem 1rem; margin-bottom: .6rem;
}
.reme h2 { color: #9a3412; }
.reme-item { padding: .55rem 0; border-top: 1px dashed #fdba74; }
.reme-item:first-of-type { border-top: none; padding-top: .25rem; }
.reme-item h3 { color: #9a3412; margin-bottom: .25rem; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
td.sev-crit { color: #991b1b; font-weight: 600; }
td.sev-warn { color: #92400e; font-weight: 600; }
td.sev-info { color: #1e40af; font-weight: 600; }
.kv { display: grid; grid-template-columns: max-content 1fr; column-gap: .8rem; row-gap: .15rem; font-size: .85rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
.pill {
display: inline-block; padding: .1em .55em; border-radius: 9999px;
font-size: .75rem; font-weight: 600; margin-right: .25rem; margin-bottom: .15rem;
}
.pill-on { background: #d1fae5; color: #065f46; }
.pill-off { background: #fee2e2; color: #991b1b; }
.mono { font-family: ui-monospace, monospace; word-break: break-all; }
.note { color: #6b7280; font-size: .85rem; }
.findings-empty { color: #065f46; padding: .4rem 0; }
</style>
</head>
<body>
<div class="hd">
<h1>
{{if eq .Kind "openpgpkey"}}OPENPGPKEY record{{else}}SMIMEA record{{end}}
<span class="badge {{.Badge}}">{{.Headline}}</span>
</h1>
<div class="sub">
Queried: <code>{{.QueriedOwner}}</code>
{{if and .ExpectedOwner (ne .ExpectedOwner .QueriedOwner)}} &middot; expected <code>{{.ExpectedOwner}}</code>{{end}}
{{if .Resolver}} &middot; via <code>{{.Resolver}}</code>{{end}}
{{if eq .DNSSEC "secure"}} &middot; <span class="badge ok">DNSSEC </span>
{{else if eq .DNSSEC "insecure"}} &middot; <span class="badge fail">DNSSEC </span>
{{else}} &middot; <span class="badge neutral">DNSSEC ?</span>{{end}}
{{if .Username}} &middot; user <code>{{.Username}}</code>{{end}}
</div>
</div>
{{if .Remediations}}
<div class="reme">
<h2>Most common issues (fix these first)</h2>
{{range .Remediations}}
<div class="reme-item">
<h3>{{.Title}}</h3>
<div>{{.Body}}</div>
</div>
{{end}}
</div>
{{end}}
{{with .OpenPGP}}
<div class="section">
<h2>OpenPGP key</h2>
<dl class="kv">
<dt>Fingerprint</dt><dd class="mono">{{.Fingerprint}}</dd>
<dt>Key ID</dt><dd class="mono">{{.KeyID}}</dd>
<dt>Algorithm</dt><dd>{{.Algorithm}}{{if .Bits}} &middot; {{.Bits}} bits{{end}}</dd>
<dt>Created</dt><dd>{{.Created}}</dd>
<dt>Expires</dt><dd>{{.Expires}}</dd>
<dt>Revoked</dt><dd>{{if .Revoked}}<span class="pill pill-off">revoked</span>{{else}}<span class="pill pill-on">no</span>{{end}}</dd>
<dt>Encrypt-capable</dt><dd>{{if .Encrypt}}<span class="pill pill-on">yes</span>{{else}}<span class="pill pill-off">no</span>{{end}}</dd>
<dt>Record size</dt><dd>{{.RawSize}} bytes{{if gt .EntityCount 1}} &middot; {{.EntityCount}} entities{{end}}</dd>
<dt>Identities</dt><dd>{{range .UIDs}}<div class="mono">{{.}}</div>{{else}}<span class="note">(none)</span>{{end}}</dd>
</dl>
{{if .Subkeys}}
<h3 style="margin-top:.8rem">Subkeys</h3>
<table>
<tr><th>Algorithm</th><th>Bits</th><th>Capabilities</th><th>Created</th><th>Expires</th><th>State</th></tr>
{{range .Subkeys}}
<tr>
<td>{{.Algorithm}}</td>
<td>{{if .Bits}}{{.Bits}}{{end}}</td>
<td>{{.Capabilities}}</td>
<td>{{.Created}}</td>
<td>{{.Expires}}</td>
<td>{{if .Revoked}}<span class="pill pill-off">revoked</span>{{else}}<span class="pill pill-on">ok</span>{{end}}</td>
</tr>
{{end}}
</table>
{{end}}
</div>
{{end}}
{{with .SMIMEA}}
<div class="section">
<h2>SMIMEA record</h2>
<dl class="kv">
<dt>Usage</dt><dd>{{.Usage}}</dd>
<dt>Selector</dt><dd>{{.Selector}}</dd>
<dt>Matching type</dt><dd>{{.MatchingType}}</dd>
{{if .HashOnly}}
<dt>Digest</dt><dd class="mono">{{.HashHex}}</dd>
{{end}}
{{if .Subject}}
<dt>Subject</dt><dd class="mono">{{.Subject}}</dd>
<dt>Issuer</dt><dd class="mono">{{.Issuer}}</dd>
<dt>Serial</dt><dd class="mono">{{.Serial}}</dd>
<dt>Valid from</dt><dd>{{.NotBefore}}</dd>
<dt>Valid until</dt><dd>{{.NotAfter}}</dd>
<dt>Signature</dt><dd>{{.SignatureAlgo}}</dd>
<dt>Public key</dt><dd>{{.KeyAlgo}}{{if .Bits}} &middot; {{.Bits}} bits{{end}}</dd>
<dt>Emails</dt><dd>{{range .Emails}}<code>{{.}}</code> {{else}}<span class="note">(none)</span>{{end}}</dd>
<dt>Flags</dt><dd>
{{if .EmailProtection}}<span class="pill pill-on">emailProtection</span>{{else}}<span class="pill pill-off">no emailProtection EKU</span>{{end}}
{{if .DigitalSignature}}<span class="pill pill-on">digitalSignature</span>{{end}}
{{if .KeyEncipherment}}<span class="pill pill-on">keyEncipherment</span>{{end}}
{{if .SelfSigned}}<span class="pill pill-off">self-signed</span>{{end}}
{{if .IsCA}}<span class="pill pill-off">CA</span>{{end}}
</dd>
{{else if and .HashOnly .HashHex}}
<dt>Certificate</dt><dd class="note">Digest only; see remediation below.</dd>
{{end}}
</dl>
</div>
{{end}}
<div class="section">
{{if .HasStates}}
<h2>Findings {{if .CritCount}}<span class="badge fail">{{.CritCount}} crit</span>{{end}}
{{if .WarnCount}}<span class="badge warn">{{.WarnCount}} warn</span>{{end}}
{{if .InfoCount}}<span class="badge info">{{.InfoCount}} info</span>{{end}}</h2>
{{if .Findings}}
<table>
<tr><th>Severity</th><th>Code</th><th>Message</th><th>Fix</th></tr>
{{range .Findings}}
<tr>
<td class="sev-{{.Severity}}">{{.Severity}}</td>
<td><code>{{.Code}}</code></td>
<td>{{.Message}}</td>
<td>{{.Fix}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="findings-empty">No issues detected.</p>
{{end}}
{{end}}
<p class="note" style="margin-top:.6rem">Collected at {{.CollectedAt}}</p>
</div>
</body>
</html>`))

109
checker/rule.go Normal file
View file

@ -0,0 +1,109 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// issue is a rule-internal description of a failed test. Rules return a
// slice of issues from their check func; Evaluate converts them to
// sdk.CheckState.
type issue struct {
Severity sdk.Status // StatusInfo / StatusWarn / StatusCrit
Message string
Hint string // remediation hint; surfaced as Meta["hint"]
Subject string // optional; overrides default data.QueriedOwner
}
// ruleFunc consumes the facts + runtime options and returns zero or more
// issues. No issues means the test passed.
type ruleFunc func(d *EmailKeyData, opts sdk.CheckerOptions) []issue
// rule is a data-driven CheckRule. All per-test rules share this type;
// only name / description / applicable kinds / options / check differ.
type rule struct {
name string
description string
okMessage string // message for StatusOK returns
kinds []string // applicable kinds; empty = both
options sdk.CheckerOptionsDocumentation // per-rule options
check ruleFunc
}
func (r *rule) Name() string { return r.name }
func (r *rule) Description() string { return r.description }
func (r *rule) Options() sdk.CheckerOptionsDocumentation { return r.options }
func (r *rule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data EmailKeyData
if err := obs.Get(ctx, ObservationKey, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to read observation %q: %v", ObservationKey, err),
Code: "openpgpkey_observation_error",
}}
}
if len(r.kinds) > 0 && !containsString(r.kinds, data.Kind) {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Message: fmt.Sprintf("Not applicable for %s records.", data.Kind),
Code: r.name,
Subject: data.QueriedOwner,
}}
}
issues := r.check(&data, opts)
if len(issues) == 0 {
msg := r.okMessage
if msg == "" {
msg = "Check passed."
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Message: msg,
Code: r.name,
Subject: data.QueriedOwner,
}}
}
states := make([]sdk.CheckState, 0, len(issues))
for _, iss := range issues {
subject := iss.Subject
if subject == "" {
subject = data.QueriedOwner
}
var meta map[string]any
if iss.Hint != "" {
meta = map[string]any{"hint": iss.Hint}
}
states = append(states, sdk.CheckState{
Status: iss.Severity,
Message: iss.Message,
Code: r.name,
Subject: subject,
Meta: meta,
})
}
return states
}
// Rules returns the full set of per-test rules for this checker.
func Rules() []sdk.CheckRule {
out := make([]sdk.CheckRule, len(allRules))
for i := range allRules {
out[i] = allRules[i]
}
return out
}
func containsString(hay []string, needle string) bool {
for _, v := range hay {
if v == needle {
return true
}
}
return false
}

300
checker/rules_check.go Normal file
View file

@ -0,0 +1,300 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule names. Each name is also the CheckState.Code emitted by the
// corresponding rule. They are kept as exported constants so callers
// (e.g. the report layer's remediation picker) can reference them
// without copying strings.
const (
RuleDNSQueryFailed = "dns_query_failed"
RuleDNSNoRecord = "dns_no_record"
RuleDNSRecordMismatch = "dns_record_mismatch"
RuleDNSSECNotValidated = "dnssec_not_validated"
RuleOwnerHashMismatch = "owner_hash_mismatch"
RulePGPParseError = "pgp_parse_error"
RulePGPPrimaryRevoked = "pgp_primary_revoked"
RulePGPPrimaryExpired = "pgp_primary_expired"
RulePGPPrimaryExpiring = "pgp_primary_expiring_soon"
RulePGPWeakAlgorithm = "pgp_weak_algorithm"
RulePGPWeakKeySize = "pgp_weak_key_size"
RulePGPNoEncryption = "pgp_no_encryption_subkey"
RulePGPNoIdentity = "pgp_no_identity"
RulePGPUIDMismatch = "pgp_uid_mismatch"
RulePGPMultipleEntities = "pgp_multiple_entities"
RulePGPRecordTooLarge = "pgp_record_too_large"
RuleSMIMEABadUsage = "smimea_bad_usage"
RuleSMIMEABadSelector = "smimea_bad_selector"
RuleSMIMEABadMatchType = "smimea_bad_match_type"
RuleSMIMEACertParseError = "smimea_cert_parse_error"
RuleSMIMEACertNotYetValid = "smimea_cert_not_yet_valid"
RuleSMIMEACertExpired = "smimea_cert_expired"
RuleSMIMEACertExpiring = "smimea_cert_expiring_soon"
RuleSMIMEANoEmailProtect = "smimea_no_email_protection_eku"
RuleSMIMEAMissingKeyUsage = "smimea_missing_key_usage"
RuleSMIMEAWeakSigAlgorithm = "smimea_weak_signature_algorithm"
RuleSMIMEAWeakKeySize = "smimea_weak_key_size"
RuleSMIMEASelfSigned = "smimea_self_signed"
RuleSMIMEAEmailMismatch = "smimea_email_mismatch"
RuleSMIMEAHashOnly = "smimea_hash_only"
)
var kindsOpenPGP = []string{KindOpenPGPKey}
var kindsSMIMEA = []string{KindSMIMEA}
// optExpiryWarn is the per-rule option documentation for
// OptionCertExpiryWarnDays. The same option id is shared by the PGP
// expiring-soon rule and the SMIMEA expiring-soon rule.
var optExpiryWarn = sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{{
Id: OptionCertExpiryWarnDays,
Type: "number",
Label: "Expiry warning threshold (days)",
Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.",
Default: float64(30),
}},
}
var optRequireDNSSEC = sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{{
Id: OptionRequireDNSSEC,
Type: "bool",
Label: "Require DNSSEC",
Description: "When enabled, a non-DNSSEC-validated lookup is reported as critical (otherwise as warning). RFC 7929 and RFC 8162 mandate DNSSEC.",
Default: true,
}},
}
var optRequireEmailProtection = sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{{
Id: OptionRequireEmailProtection,
Type: "bool",
Label: "Require emailProtection EKU",
Description: "When enabled, an S/MIME certificate without the emailProtection Extended Key Usage is reported as critical.",
Default: true,
}},
}
// allRules is the canonical list of rules this checker exposes. Each
// entry registers one CheckRule, implemented by the check<Name> funcs
// in rules_dns.go, rules_pgp.go, and rules_smimea.go.
var allRules = []*rule{
// ── DNS / owner (both kinds), rules_dns.go ──
{
name: RuleDNSQueryFailed,
description: "The DNS lookup for the OPENPGPKEY/SMIMEA record must succeed.",
okMessage: "DNS lookup succeeded.",
check: checkDNSQueryFailed,
},
{
name: RuleDNSNoRecord,
description: "An OPENPGPKEY/SMIMEA record must be published at the expected owner name.",
okMessage: "A record is published at the queried owner name.",
check: checkDNSNoRecord,
},
{
name: RuleDNSRecordMismatch,
description: "The record returned by DNS must match the service-declared record.",
okMessage: "DNS matches the service-declared record.",
check: checkDNSRecordMismatch,
},
{
name: RuleDNSSECNotValidated,
description: "The record must be authenticated by DNSSEC; RFC 7929 and RFC 8162 mandate it.",
okMessage: "DNSSEC validated the record (AD flag set).",
options: optRequireDNSSEC,
check: checkDNSSECNotValidated,
},
{
name: RuleOwnerHashMismatch,
description: "The first label of the owner name must equal hex(sha256(username))[:28].",
okMessage: "Owner-name hash matches the username.",
check: checkOwnerHashMismatch,
},
// ── OpenPGP (kind openpgpkey), rules_pgp.go ──
{
name: RulePGPParseError,
description: "The OPENPGPKEY record must decode as a valid OpenPGP key.",
okMessage: "OpenPGP key parsed successfully.",
kinds: kindsOpenPGP,
check: checkPGPParseError,
},
{
name: RulePGPPrimaryRevoked,
description: "The OpenPGP primary key must not carry a revocation signature.",
okMessage: "Primary key is not revoked.",
kinds: kindsOpenPGP,
check: checkPGPPrimaryRevoked,
},
{
name: RulePGPPrimaryExpired,
description: "The OpenPGP primary key must not be past its self-signature expiry.",
okMessage: "Primary key is not expired.",
kinds: kindsOpenPGP,
check: checkPGPPrimaryExpired,
},
{
name: RulePGPPrimaryExpiring,
description: "Warn when the OpenPGP primary key expires within the configured window.",
okMessage: "Primary key is not expiring soon.",
kinds: kindsOpenPGP,
options: optExpiryWarn,
check: checkPGPPrimaryExpiring,
},
{
name: RulePGPWeakAlgorithm,
description: "The OpenPGP keys must not use legacy algorithms (DSA/ElGamal).",
okMessage: "All OpenPGP keys use modern algorithms.",
kinds: kindsOpenPGP,
check: checkPGPWeakAlgorithm,
},
{
name: RulePGPWeakKeySize,
description: "OpenPGP RSA keys must be at least 2048 bits (NIST SP 800-131A); 3072+ preferred.",
okMessage: "All RSA OpenPGP keys meet the minimum key size.",
kinds: kindsOpenPGP,
check: checkPGPWeakKeySize,
},
{
name: RulePGPNoEncryption,
description: "At least one active (non-revoked, non-expired) OpenPGP key must advertise encryption capability.",
okMessage: "The entity has an active encryption-capable key.",
kinds: kindsOpenPGP,
check: checkPGPNoEncryption,
},
{
name: RulePGPNoIdentity,
description: "The OpenPGP key must carry at least one self-signed User ID.",
okMessage: "The OpenPGP key has at least one identity.",
kinds: kindsOpenPGP,
check: checkPGPNoIdentity,
},
{
name: RulePGPUIDMismatch,
description: "At least one OpenPGP UID should reference <username@…>.",
okMessage: "At least one UID matches the username.",
kinds: kindsOpenPGP,
check: checkPGPUIDMismatch,
},
{
name: RulePGPMultipleEntities,
description: "RFC 7929 recommends a single OpenPGP entity per record.",
okMessage: "The record carries a single OpenPGP entity.",
kinds: kindsOpenPGP,
check: checkPGPMultipleEntities,
},
{
name: RulePGPRecordTooLarge,
description: "The OPENPGPKEY record should stay below 4 KiB to fit typical UDP answers.",
okMessage: "Record size is within the recommended limit.",
kinds: kindsOpenPGP,
check: checkPGPRecordTooLarge,
},
// ── SMIMEA (kind smimea), rules_smimea.go ──
{
name: RuleSMIMEABadUsage,
description: "SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA), or 3 (DANE-EE).",
okMessage: "SMIMEA usage is valid.",
kinds: kindsSMIMEA,
check: checkSMIMEABadUsage,
},
{
name: RuleSMIMEABadSelector,
description: "SMIMEA selector must be 0 (Cert) or 1 (SPKI).",
okMessage: "SMIMEA selector is valid.",
kinds: kindsSMIMEA,
check: checkSMIMEABadSelector,
},
{
name: RuleSMIMEABadMatchType,
description: "SMIMEA matching type must be 0 (Full), 1 (SHA-256), or 2 (SHA-512).",
okMessage: "SMIMEA matching type is valid.",
kinds: kindsSMIMEA,
check: checkSMIMEABadMatchType,
},
{
name: RuleSMIMEACertParseError,
description: "The SMIMEA record must decode as a valid X.509 certificate (or SPKI, for selector 1).",
okMessage: "Certificate parsed successfully.",
kinds: kindsSMIMEA,
check: checkSMIMEACertParseError,
},
{
name: RuleSMIMEACertNotYetValid,
description: "The S/MIME certificate's NotBefore must be in the past.",
okMessage: "Certificate is within its validity window.",
kinds: kindsSMIMEA,
check: checkSMIMEACertNotYetValid,
},
{
name: RuleSMIMEACertExpired,
description: "The S/MIME certificate's NotAfter must be in the future.",
okMessage: "Certificate is not expired.",
kinds: kindsSMIMEA,
check: checkSMIMEACertExpired,
},
{
name: RuleSMIMEACertExpiring,
description: "Warn when the S/MIME certificate expires within the configured window.",
okMessage: "Certificate is not expiring soon.",
kinds: kindsSMIMEA,
options: optExpiryWarn,
check: checkSMIMEACertExpiring,
},
{
name: RuleSMIMEANoEmailProtect,
description: "The S/MIME certificate must advertise the emailProtection Extended Key Usage (RFC 8550/8551).",
okMessage: "Certificate carries emailProtection EKU.",
kinds: kindsSMIMEA,
options: optRequireEmailProtection,
check: checkSMIMEANoEmailProtect,
},
{
name: RuleSMIMEAMissingKeyUsage,
description: "The S/MIME certificate must carry digitalSignature and/or keyEncipherment key usage.",
okMessage: "Certificate carries the expected key usages.",
kinds: kindsSMIMEA,
check: checkSMIMEAMissingKeyUsage,
},
{
name: RuleSMIMEAWeakSigAlgorithm,
description: "The certificate must not be signed with a deprecated algorithm (MD2/MD5/SHA-1 based).",
okMessage: "Certificate uses a strong signature algorithm.",
kinds: kindsSMIMEA,
check: checkSMIMEAWeakSigAlgorithm,
},
{
name: RuleSMIMEAWeakKeySize,
description: "SMIMEA RSA keys must be at least 2048 bits; 3072+ preferred.",
okMessage: "Certificate key size meets the minimum.",
kinds: kindsSMIMEA,
check: checkSMIMEAWeakKeySize,
},
{
name: RuleSMIMEASelfSigned,
description: "Self-signed certificates with PKIX-EE (usage 1) are rejected by standard clients.",
okMessage: "Certificate chain is appropriate for the declared usage.",
kinds: kindsSMIMEA,
check: checkSMIMEASelfSigned,
},
{
name: RuleSMIMEAEmailMismatch,
description: "At least one email SAN on the certificate should begin with <username>@.",
okMessage: "At least one email SAN matches the username.",
kinds: kindsSMIMEA,
check: checkSMIMEAEmailMismatch,
},
{
name: RuleSMIMEAHashOnly,
description: "SMIMEA matching types 1/2 transport only a digest; the certificate cannot be verified.",
okMessage: "Full certificate is published.",
kinds: kindsSMIMEA,
check: checkSMIMEAHashOnly,
},
}

78
checker/rules_dns.go Normal file
View file

@ -0,0 +1,78 @@
package checker
import (
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// DNS-level rules: lookup outcome, record presence, service/DNS parity,
// DNSSEC authentication, and owner-name hash correctness. These apply
// to both OPENPGPKEY and SMIMEA records.
func checkDNSQueryFailed(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.DNSQueryError == "" {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: d.DNSQueryError,
Hint: "Check that the zone is published at an authoritative server reachable from this checker.",
}}
}
func checkDNSNoRecord(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.DNSAnswerPresent == nil || *d.DNSAnswerPresent {
return nil
}
kind := "OPENPGPKEY"
if d.Kind == KindSMIMEA {
kind = "SMIMEA"
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("Authoritative DNS returned no %s record at %s.", kind, d.QueriedOwner),
Hint: "Ensure the record is present in the zone and that the zone has been loaded by the authoritative servers.",
}}
}
func checkDNSRecordMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.DNSRecordMatchesService == nil || *d.DNSRecordMatchesService {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
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.",
Hint: "Propagate the zone to the authoritative servers, then wait for TTL/negative-cache expiry.",
}}
}
func checkDNSSECNotValidated(d *EmailKeyData, opts sdk.CheckerOptions) []issue {
if d.DNSSECSecure == nil || *d.DNSSECSecure {
return nil
}
sev := sdk.StatusWarn
if sdk.GetBoolOption(opts, OptionRequireDNSSEC, true) {
sev = sdk.StatusCrit
}
return []issue{{
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.",
Hint: "Sign the zone with DNSSEC and publish the DS record at the parent so RFC 7929/8162 consumers can authenticate the key.",
}}
}
func checkOwnerHashMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.ExpectedOwnerPrefix == "" || d.ObservedOwnerPrefix == "" {
return nil
}
if strings.EqualFold(d.ObservedOwnerPrefix, d.ExpectedOwnerPrefix) {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("Owner name prefix %q does not match SHA-256(%q)[:28]=%q.", d.ObservedOwnerPrefix, d.Username, d.ExpectedOwnerPrefix),
Hint: "Republish the record at the hash-derived name for the intended user, or update the Username field to match the record's owner name.",
}}
}

213
checker/rules_pgp.go Normal file
View file

@ -0,0 +1,213 @@
package checker
import (
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// OpenPGP-specific rules: key parse, revocation, expiry, algorithm and
// key-size strength, encryption capability, identity presence, UID
// matching, RFC 7929 single-entity guidance and record size budget.
const pgpMaxRecordBytes = 4096
func checkPGPParseError(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil {
return []issue{{
Severity: sdk.StatusCrit,
Message: "Service body has no OPENPGPKEY record.",
Hint: "Attach a valid OPENPGPKEY record to the service.",
}}
}
if d.OpenPGP.ParseError == "" {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: d.OpenPGP.ParseError,
Hint: "Regenerate the key with `gpg --export <fpr> | base64` and paste the result; do not armor the key.",
}}
}
func checkPGPPrimaryRevoked(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || !d.OpenPGP.Revoked {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: "The OpenPGP primary key carries a revocation signature. Consumers will refuse to encrypt to it.",
Hint: "Publish a fresh, non-revoked key at this name, or withdraw the OPENPGPKEY record entirely.",
}}
}
func checkPGPPrimaryExpired(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.ExpiresAt.IsZero() {
return nil
}
if !d.OpenPGP.ExpiresAt.Before(time.Now()) {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("The OpenPGP primary key expired on %s.", d.OpenPGP.ExpiresAt.Format(time.RFC3339)),
Hint: "Extend the key's expiry (`gpg --edit-key <fpr>` → `expire`) or issue a new key and republish the OPENPGPKEY record.",
}}
}
func checkPGPPrimaryExpiring(d *EmailKeyData, opts sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.ExpiresAt.IsZero() {
return nil
}
warnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30)
if warnDays <= 0 {
return nil
}
now := time.Now()
window := time.Duration(warnDays) * 24 * time.Hour
exp := d.OpenPGP.ExpiresAt
if exp.Before(now) || exp.Sub(now) >= window {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("The OpenPGP primary key expires on %s.", exp.Format(time.RFC3339)),
Hint: "Extend the key's expiry before it lapses, then re-export and republish.",
}}
}
func checkPGPWeakAlgorithm(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil {
return nil
}
var out []issue
if isWeakPGPAlgorithm(d.OpenPGP.PrimaryAlgorithm) {
out = append(out, issue{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("Primary key uses %s, which modern OpenPGP stacks are phasing out.", d.OpenPGP.PrimaryAlgorithm),
Hint: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.",
Subject: subkeySubject(d.QueriedOwner, "primary"),
})
}
for i, sk := range d.OpenPGP.Subkeys {
if isWeakPGPAlgorithm(sk.Algorithm) {
out = append(out, issue{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("Subkey #%d uses %s, which modern OpenPGP stacks are phasing out.", i+1, sk.Algorithm),
Hint: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.",
Subject: subkeySubject(d.QueriedOwner, fmt.Sprintf("subkey-%d", i+1)),
})
}
}
return out
}
func checkPGPWeakKeySize(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil {
return nil
}
var out []issue
if iss := rsaKeySizeIssue(d.OpenPGP.PrimaryAlgorithm, d.OpenPGP.PrimaryBits, "OpenPGP primary"); iss != nil {
iss.Subject = subkeySubject(d.QueriedOwner, "primary")
out = append(out, *iss)
}
for i, sk := range d.OpenPGP.Subkeys {
if iss := rsaKeySizeIssue(sk.Algorithm, sk.Bits, fmt.Sprintf("OpenPGP subkey #%d", i+1)); iss != nil {
iss.Subject = subkeySubject(d.QueriedOwner, fmt.Sprintf("subkey-%d", i+1))
out = append(out, *iss)
}
}
return out
}
func checkPGPNoEncryption(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.ParseError != "" || d.OpenPGP.HasEncryptionCapability {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: "No active (non-revoked, non-expired) key in the entity advertises encryption capability. The record is useless for email encryption.",
Hint: "Generate an encryption subkey (`gpg --edit-key <fpr>` → `addkey`) and re-export.",
}}
}
func checkPGPNoIdentity(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.ParseError != "" || len(d.OpenPGP.UIDs) > 0 {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
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.",
Hint: "Add a UID containing the user's email (e.g. `gpg --edit-key <fpr>` → `adduid`) and re-export.",
}}
}
func checkPGPUIDMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.MatchesUsername == nil || *d.OpenPGP.MatchesUsername {
return nil
}
return []issue{{
Severity: sdk.StatusInfo,
Message: fmt.Sprintf("None of the OpenPGP UIDs reference <%s@…>.", d.Username),
Hint: "Add a UID bound to the email address that the record attests to.",
}}
}
func checkPGPMultipleEntities(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.EntityCount <= 1 {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("The record contains %d OpenPGP entities; RFC 7929 recommends a single entity per OPENPGPKEY record.", d.OpenPGP.EntityCount),
Hint: "Split each user's key into its own OPENPGPKEY RR.",
}}
}
func checkPGPRecordTooLarge(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.RawSize <= pgpMaxRecordBytes {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
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.", d.OpenPGP.RawSize),
Hint: "Publish only the minimum key material needed for email encryption (primary + encryption subkey) and strip image UIDs / extra attributes before export.",
}}
}
func isWeakPGPAlgorithm(name string) bool {
return name == "DSA" || name == "ElGamal"
}
// rsaKeySizeIssue returns a non-nil *issue when the given RSA key is
// below NIST's deprecation (2048) or recommendation (3072) thresholds.
// Returns nil for non-RSA algorithms or when bits is 0 (unknown).
func rsaKeySizeIssue(algorithm string, bits int, label string) *issue {
if !strings.EqualFold(algorithm, "RSA") || bits == 0 {
return nil
}
if bits < 2048 {
return &issue{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("%s RSA key of %d bits is considered broken. NIST SP 800-131A deprecates anything below 2048 bits.", label, bits),
Hint: "Generate a fresh RSA-3072/4096 or Ed25519/Curve25519 key and republish.",
}
}
if bits < 3072 {
return &issue{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("%s RSA-%d is aging; NIST recommends at least 3072 bits for new deployments.", label, bits),
Hint: "Plan a migration to RSA-3072/4096 or Ed25519/Curve25519 at the next key rotation.",
}
}
return nil
}
func subkeySubject(owner, label string) string {
if owner == "" {
return label
}
return owner + " [" + label + "]"
}

231
checker/rules_smimea.go Normal file
View file

@ -0,0 +1,231 @@
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
}

330
checker/rules_test.go Normal file
View file

@ -0,0 +1,330 @@
package checker
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func boolPtr(b bool) *bool { return &b }
// fakeObs implements sdk.ObservationGetter against an in-memory map.
type fakeObs struct {
store map[string]any
}
func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
v, ok := f.store[key]
if !ok {
return errFake("missing observation: " + key)
}
raw, err := json.Marshal(v)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
type errFake string
func (e errFake) Error() string { return string(e) }
// ── DNS rules ────────────────────────────────────────────────────────────────
func TestCheckDNSQueryFailed(t *testing.T) {
if got := checkDNSQueryFailed(&EmailKeyData{}, nil); got != nil {
t.Errorf("expected no issue, got %+v", got)
}
got := checkDNSQueryFailed(&EmailKeyData{DNSQueryError: "timeout"}, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("expected one crit issue, got %+v", got)
}
}
func TestCheckDNSNoRecord(t *testing.T) {
// nil DNSAnswerPresent ⇒ no judgement.
if got := checkDNSNoRecord(&EmailKeyData{}, nil); got != nil {
t.Errorf("expected no issue when present is nil, got %+v", got)
}
// Present=true ⇒ no issue.
if got := checkDNSNoRecord(&EmailKeyData{DNSAnswerPresent: boolPtr(true)}, nil); got != nil {
t.Errorf("expected no issue when present, got %+v", got)
}
// Present=false ⇒ crit.
got := checkDNSNoRecord(&EmailKeyData{Kind: KindSMIMEA, QueriedOwner: "x", DNSAnswerPresent: boolPtr(false)}, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit || !strings.Contains(got[0].Message, "SMIMEA") {
t.Errorf("unexpected: %+v", got)
}
}
func TestCheckDNSSECNotValidated_Severity(t *testing.T) {
d := &EmailKeyData{DNSSECSecure: boolPtr(false)}
// Default: requireDNSSEC=true ⇒ crit.
got := checkDNSSECNotValidated(d, sdk.CheckerOptions{})
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("default should be crit, got %+v", got)
}
// Override to false ⇒ warn.
got = checkDNSSECNotValidated(d, sdk.CheckerOptions{OptionRequireDNSSEC: false})
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
t.Errorf("opt-off should be warn, got %+v", got)
}
// Secure ⇒ no issue.
got = checkDNSSECNotValidated(&EmailKeyData{DNSSECSecure: boolPtr(true)}, nil)
if got != nil {
t.Errorf("expected no issue, got %+v", got)
}
}
func TestCheckOwnerHashMismatch(t *testing.T) {
d := &EmailKeyData{Username: "alice", ExpectedOwnerPrefix: "abc", ObservedOwnerPrefix: "abc"}
if got := checkOwnerHashMismatch(d, nil); got != nil {
t.Errorf("matching prefixes should not issue, got %+v", got)
}
d.ObservedOwnerPrefix = "ABC" // case-insensitive
if got := checkOwnerHashMismatch(d, nil); got != nil {
t.Errorf("case-insensitive match should not issue, got %+v", got)
}
d.ObservedOwnerPrefix = "xyz"
got := checkOwnerHashMismatch(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("mismatch should crit, got %+v", got)
}
// Either prefix empty ⇒ skip silently.
d.ObservedOwnerPrefix = ""
if got := checkOwnerHashMismatch(d, nil); got != nil {
t.Errorf("empty observed should skip, got %+v", got)
}
}
// ── PGP rules ────────────────────────────────────────────────────────────────
func TestCheckPGPParseError(t *testing.T) {
got := checkPGPParseError(&EmailKeyData{}, nil)
if len(got) != 1 || !strings.Contains(got[0].Message, "no OPENPGPKEY") {
t.Errorf("expected no-record issue, got %+v", got)
}
got = checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{ParseError: "boom"}}, nil)
if len(got) != 1 || got[0].Message != "boom" {
t.Errorf("expected parse-error issue, got %+v", got)
}
if got := checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{}}, nil); got != nil {
t.Errorf("expected no issue, got %+v", got)
}
}
func TestCheckPGPPrimaryExpired(t *testing.T) {
past := time.Now().Add(-1 * time.Hour)
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: past}}
got := checkPGPPrimaryExpired(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("expected crit, got %+v", got)
}
d.OpenPGP.ExpiresAt = time.Now().Add(time.Hour)
if got := checkPGPPrimaryExpired(d, nil); got != nil {
t.Errorf("future expiry should not issue, got %+v", got)
}
}
func TestCheckPGPPrimaryExpiring(t *testing.T) {
soon := time.Now().Add(10 * 24 * time.Hour)
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: soon}}
// Default 30-day window ⇒ warn.
got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{})
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
t.Errorf("expected warn, got %+v", got)
}
// Already expired ⇒ this rule does not fire (the expired rule does).
d.OpenPGP.ExpiresAt = time.Now().Add(-time.Hour)
if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{}); got != nil {
t.Errorf("expired key should not trigger expiring rule, got %+v", got)
}
// Disable via warnDays=0 ⇒ no issue.
d.OpenPGP.ExpiresAt = soon
if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{OptionCertExpiryWarnDays: float64(0)}); got != nil {
t.Errorf("warnDays=0 should disable, got %+v", got)
}
}
func TestCheckPGPWeakKeySize(t *testing.T) {
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{PrimaryAlgorithm: "RSA", PrimaryBits: 1024}}
got := checkPGPWeakKeySize(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("1024-bit RSA should be crit, got %+v", got)
}
d.OpenPGP.PrimaryBits = 2048
got = checkPGPWeakKeySize(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
t.Errorf("2048-bit RSA should be warn, got %+v", got)
}
d.OpenPGP.PrimaryBits = 4096
if got := checkPGPWeakKeySize(d, nil); got != nil {
t.Errorf("4096-bit RSA should pass, got %+v", got)
}
// Non-RSA ⇒ skip.
d.OpenPGP.PrimaryAlgorithm = "Ed25519"
d.OpenPGP.PrimaryBits = 256
if got := checkPGPWeakKeySize(d, nil); got != nil {
t.Errorf("Ed25519 should skip, got %+v", got)
}
}
func TestCheckPGPRecordTooLarge(t *testing.T) {
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{RawSize: pgpMaxRecordBytes + 1}}
got := checkPGPRecordTooLarge(d, nil)
if len(got) != 1 {
t.Errorf("expected one issue, got %+v", got)
}
d.OpenPGP.RawSize = pgpMaxRecordBytes
if got := checkPGPRecordTooLarge(d, nil); got != nil {
t.Errorf("at-limit should pass, got %+v", got)
}
}
func TestCheckPGPUIDMismatch(t *testing.T) {
d := &EmailKeyData{Username: "alice", OpenPGP: &OpenPGPInfo{MatchesUsername: boolPtr(false)}}
got := checkPGPUIDMismatch(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusInfo {
t.Errorf("expected info issue, got %+v", got)
}
d.OpenPGP.MatchesUsername = boolPtr(true)
if got := checkPGPUIDMismatch(d, nil); got != nil {
t.Errorf("matching should pass, got %+v", got)
}
d.OpenPGP.MatchesUsername = nil
if got := checkPGPUIDMismatch(d, nil); got != nil {
t.Errorf("nil should skip, got %+v", got)
}
}
// ── SMIMEA rules ─────────────────────────────────────────────────────────────
func TestCheckSMIMEAFieldRanges(t *testing.T) {
if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 4}}, nil); len(got) != 1 {
t.Errorf("usage=4 should issue, got %+v", got)
}
if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 3}}, nil); got != nil {
t.Errorf("usage=3 should pass, got %+v", got)
}
if got := checkSMIMEABadSelector(&EmailKeyData{SMIMEA: &SMIMEAInfo{Selector: 2}}, nil); len(got) != 1 {
t.Errorf("selector=2 should issue")
}
if got := checkSMIMEABadMatchType(&EmailKeyData{SMIMEA: &SMIMEAInfo{MatchingType: 3}}, nil); len(got) != 1 {
t.Errorf("matching=3 should issue")
}
}
func TestCheckSMIMEACertExpired(t *testing.T) {
past := time.Now().Add(-time.Hour)
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{NotAfter: past}}}
got := checkSMIMEACertExpired(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("expected crit, got %+v", got)
}
}
func TestCheckSMIMEANoEmailProtect_Severity(t *testing.T) {
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{}}}
// Default true ⇒ crit.
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("default crit, got %+v", got)
}
// Off ⇒ warn.
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{OptionRequireEmailProtection: false}); got[0].Severity != sdk.StatusWarn {
t.Errorf("opt-off should warn, got %+v", got)
}
// Has EKU ⇒ no issue.
d.SMIMEA.Certificate.HasEmailProtectionEKU = true
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); got != nil {
t.Errorf("EKU present should pass, got %+v", got)
}
}
func TestCheckSMIMEAWeakSigAlgorithm(t *testing.T) {
for _, algo := range []string{"MD5-RSA", "SHA1-RSA"} {
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: algo}}}
if got := checkSMIMEAWeakSigAlgorithm(d, nil); len(got) != 1 {
t.Errorf("%s should issue", algo)
}
}
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: "SHA256-RSA"}}}
if got := checkSMIMEAWeakSigAlgorithm(d, nil); got != nil {
t.Errorf("SHA256-RSA should pass, got %+v", got)
}
}
func TestCheckSMIMEAEmailMismatch(t *testing.T) {
d := &EmailKeyData{Username: "alice", SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{
EmailAddresses: []string{"bob@example.com"},
EmailMatchesUsername: boolPtr(false),
}}}
got := checkSMIMEAEmailMismatch(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusInfo {
t.Errorf("expected info, got %+v", got)
}
}
// ── Rule.Evaluate plumbing ───────────────────────────────────────────────────
func TestRuleEvaluate_OKPath(t *testing.T) {
obs := &fakeObs{store: map[string]any{
ObservationKey: &EmailKeyData{Kind: KindOpenPGPKey, QueriedOwner: "x.example.com.", DNSSECSecure: boolPtr(true)},
}}
for _, r := range allRules {
if r.name != RuleDNSSECNotValidated {
continue
}
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusOK || states[0].Code != RuleDNSSECNotValidated {
t.Fatalf("expected single OK state, got %+v", states)
}
}
}
func TestRuleEvaluate_KindFiltering(t *testing.T) {
obs := &fakeObs{store: map[string]any{
ObservationKey: &EmailKeyData{Kind: KindSMIMEA},
}}
for _, r := range allRules {
if r.name != RulePGPParseError {
continue
}
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusInfo {
t.Fatalf("PGP rule on SMIMEA kind should yield single Info state, got %+v", states)
}
}
}
func TestRuleEvaluate_MissingObservation(t *testing.T) {
obs := &fakeObs{store: map[string]any{}}
r := allRules[0]
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusError {
t.Fatalf("expected single Error state, got %+v", states)
}
}
func TestRulesUniqueNames(t *testing.T) {
seen := map[string]bool{}
for _, r := range allRules {
if seen[r.name] {
t.Errorf("duplicate rule name: %s", r.name)
}
seen[r.name] = true
if r.check == nil {
t.Errorf("rule %s has nil check func", r.name)
}
if r.okMessage == "" {
t.Errorf("rule %s has empty okMessage", r.name)
}
}
}

219
checker/types.go Normal file
View file

@ -0,0 +1,219 @@
// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for
// happyDomain. It gathers the facts published by a zone for an
// abstract.OpenPGP or abstract.SMimeCert service (DNS lookup, DNSSEC
// flag, parsed OpenPGP key, parsed X.509 certificate) and lets a
// family of per-test rules judge those facts.
package checker
import (
"encoding/json"
"time"
)
// ObservationKey is the key this checker publishes. The payload is an
// *EmailKeyData JSON document.
const ObservationKey = "openpgpkey_smimea"
// Supported service types.
const (
ServiceOpenPGP = "abstract.OpenPGP"
ServiceSMimeCert = "abstract.SMimeCert"
KindOpenPGPKey = "openpgpkey"
KindSMIMEA = "smimea"
OpenPGPKeyPrefix = "_openpgpkey"
SMIMEACertPrefix = "_smimecert"
DANEOwnerHashSize = 28 // bytes of SHA-256 kept as the owner prefix
)
// EmailKeyData is the observation payload written under ObservationKey.
// It carries only facts; no severities, no judgment, rules decide
// what's OK and what isn't.
type EmailKeyData struct {
// Kind is "openpgpkey" or "smimea".
Kind string `json:"kind"`
// Domain is the FQDN of the zone (origin) that publishes the record.
Domain string `json:"domain"`
// Subdomain is the relative name below Domain where the service sits
// (empty for the zone apex).
Subdomain string `json:"subdomain,omitempty"`
// Username is the local part copied from the service. When empty,
// the username-hash-prefix verification is skipped.
Username string `json:"username,omitempty"`
// ExpectedOwner is the FQDN at which the record should be published,
// per RFC 7929 / RFC 8162.
ExpectedOwner string `json:"expected_owner,omitempty"`
// QueriedOwner is the FQDN actually queried (may differ from
// ExpectedOwner if the service record already carries its own name).
QueriedOwner string `json:"queried_owner,omitempty"`
// Resolver is the DNS server that answered the lookup.
Resolver string `json:"resolver,omitempty"`
// DNSQueryError is non-empty when the DNS lookup itself failed (no
// answer received, transport error, etc.).
DNSQueryError string `json:"dns_query_error,omitempty"`
// DNSAnswerPresent is nil when the lookup did not complete, false
// when the authoritative answer was NXDOMAIN / empty, true otherwise.
DNSAnswerPresent *bool `json:"dns_answer_present,omitempty"`
// DNSSECSecure is true when the validating resolver set the AD flag
// on the answer. Nil means the lookup did not complete.
DNSSECSecure *bool `json:"dnssec_secure,omitempty"`
// DNSRecordMatchesService is the result of comparing the DNS-returned
// record bytes against the service-body bytes. Nil when the
// comparison could not run (DNS failed, or the service body has no
// record to compare against).
DNSRecordMatchesService *bool `json:"dns_record_matches_service,omitempty"`
// ObservedOwnerPrefix is the hash-shaped first label extracted from
// QueriedOwner (<hex>._openpgpkey.<…> / <hex>._smimecert.<…>), or
// empty when the owner does not follow that shape.
ObservedOwnerPrefix string `json:"observed_owner_prefix,omitempty"`
// ExpectedOwnerPrefix is hex(sha256(Username))[:28]. Empty when
// Username is empty.
ExpectedOwnerPrefix string `json:"expected_owner_prefix,omitempty"`
// RecordCount is the number of records returned at QueriedOwner.
RecordCount int `json:"record_count"`
// OpenPGP is populated for kind=openpgpkey.
OpenPGP *OpenPGPInfo `json:"openpgp,omitempty"`
// SMIMEA is populated for kind=smimea.
SMIMEA *SMIMEAInfo `json:"smimea,omitempty"`
CollectedAt time.Time `json:"collected_at"`
}
// OpenPGPInfo summarises the OpenPGP key observed in the record.
type OpenPGPInfo struct {
// ParseError is non-empty when the record could not be decoded as a
// valid OpenPGP key (bad base64, unreadable packet stream, no
// entity, or no record attached to the service at all). Remaining
// fields may be zero-valued on this path.
ParseError string `json:"parse_error,omitempty"`
// RawSize is the length in bytes of the transport key material.
RawSize int `json:"raw_size"`
// PrimaryAlgorithm is the name of the primary key's algorithm,
// e.g. "RSA", "Ed25519", "ECDSA-NIST-P-256".
PrimaryAlgorithm string `json:"primary_algorithm,omitempty"`
// PrimaryBits is the key size in bits for the primary key (0 when
// the algorithm is of fixed size, e.g. Ed25519).
PrimaryBits int `json:"primary_bits,omitempty"`
// Fingerprint is the hex-encoded OpenPGP fingerprint.
Fingerprint string `json:"fingerprint,omitempty"`
// KeyID is the short 64-bit key id, hex.
KeyID string `json:"key_id,omitempty"`
// UIDs lists the User ID strings carried in the key.
UIDs []string `json:"uids,omitempty"`
// CreatedAt is the primary key creation time.
CreatedAt time.Time `json:"created_at,omitempty"`
// ExpiresAt is the primary key expiration time (zero for "never").
ExpiresAt time.Time `json:"expires_at,omitempty"`
// Revoked is true when the primary key carries a revocation signature.
Revoked bool `json:"revoked,omitempty"`
// MatchesUsername is nil when the check was not run (no UIDs or no
// username), true when at least one UID references <username@…>,
// false otherwise.
MatchesUsername *bool `json:"matches_username,omitempty"`
// Subkeys describes the subordinate keys.
Subkeys []SubkeyInfo `json:"subkeys,omitempty"`
// EntityCount is the number of OpenPGP entities parsed from the
// record. RFC 7929 recommends a single entity per record.
EntityCount int `json:"entity_count"`
// HasEncryptionCapability is true when at least one non-revoked,
// non-expired key in the entity advertises encryption usage flags.
HasEncryptionCapability bool `json:"has_encryption_capability"`
}
// SubkeyInfo summarises one OpenPGP subkey.
type SubkeyInfo struct {
Algorithm string `json:"algorithm"`
Bits int `json:"bits,omitempty"`
CanSign bool `json:"can_sign,omitempty"`
CanEncrypt bool `json:"can_encrypt,omitempty"`
CanAuth bool `json:"can_auth,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Revoked bool `json:"revoked,omitempty"`
}
// SMIMEAInfo summarises the S/MIME record.
type SMIMEAInfo struct {
// ParseError is non-empty when the certificate / SPKI bytes cannot
// be parsed.
ParseError string `json:"parse_error,omitempty"`
Usage uint8 `json:"usage"`
Selector uint8 `json:"selector"`
MatchingType uint8 `json:"matching_type"`
// Certificate is populated when the record carries a full X.509
// certificate (selector 0, matching type 0). For selector 1 + type 0
// only PublicKey is populated. For matching types 1/2, neither is
// populated; only the digest is transported.
Certificate *CertInfo `json:"certificate,omitempty"`
PublicKey *PubKeyInfo `json:"public_key,omitempty"`
// HashHex, when set, is the hex digest embedded in the record.
HashHex string `json:"hash_hex,omitempty"`
}
// CertInfo summarises an X.509 certificate.
type CertInfo struct {
Subject string `json:"subject,omitempty"`
Issuer string `json:"issuer,omitempty"`
SerialHex string `json:"serial_hex,omitempty"`
NotBefore time.Time `json:"not_before,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
SignatureAlgorithm string `json:"signature_algorithm,omitempty"`
PublicKeyAlgorithm string `json:"public_key_algorithm,omitempty"`
PublicKeyBits int `json:"public_key_bits,omitempty"`
EmailAddresses []string `json:"email_addresses,omitempty"`
DNSNames []string `json:"dns_names,omitempty"`
HasEmailProtectionEKU bool `json:"has_email_protection_eku,omitempty"`
HasDigitalSignature bool `json:"has_digital_signature,omitempty"`
HasKeyEncipherment bool `json:"has_key_encipherment,omitempty"`
IsSelfSigned bool `json:"is_self_signed,omitempty"`
IsCA bool `json:"is_ca,omitempty"`
// EmailMatchesUsername is nil when the check was not run (no
// username or no email SAN on the certificate), true when at least
// one SAN begins with "<username>@", false otherwise.
EmailMatchesUsername *bool `json:"email_matches_username,omitempty"`
}
// PubKeyInfo summarises an SPKI-only SMIMEA record.
type PubKeyInfo struct {
Algorithm string `json:"algorithm,omitempty"`
Bits int `json:"bits,omitempty"`
}
// serviceMessage is a minimal mirror of happyDomain's ServiceMessage JSON
// envelope used to carry the auto-filled service.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}