Initial commit

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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-email-keys
checker-email-keys.so

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-email-keys .
FROM scratch
COPY --from=builder /checker-email-keys /checker-email-keys
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/checker-email-keys"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-email-keys
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

31
NOTICE Normal file
View file

@ -0,0 +1,31 @@
checker-email-keys
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0
-------------------------------------------------------------------------------
OpenPGP parsing
-------------------------------------------------------------------------------
This product uses github.com/ProtonMail/go-crypto/openpgp (fork of the
deprecated golang.org/x/crypto/openpgp), BSD-style license.
-------------------------------------------------------------------------------
DNS
-------------------------------------------------------------------------------
This product uses github.com/miekg/dns, BSD-style license.

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# checker-email-keys
DANE-Email posture checker for happyDomain.
Runs a comprehensive testsuite on a domain's DNS-published OpenPGP key
(`OPENPGPKEY`, [RFC 7929][rfc7929]) or S/MIME certificate (`SMIMEA`,
[RFC 8162][rfc8162]) and renders an actionable HTML report whose top
block nudges the user toward the fix for the most common failure
scenarios.
This checker binds to the happyDomain services:
- `abstract.OpenPGP`: individual user's PGP key, owner-hashed below
`._openpgpkey.<zone>`.
- `abstract.SMimeCert`: user's S/MIME certificate, owner-hashed below
`._smimecert.<zone>`.
[rfc7929]: https://www.rfc-editor.org/rfc/rfc7929
[rfc8162]: https://www.rfc-editor.org/rfc/rfc8162
## Security scope
This checker validates DNS publication and the structure/metadata of the
keys it finds. It does **not** cryptographically verify them:
- OpenPGP signatures (self-signatures, third-party certifications,
revocations beyond the presence of a revocation packet) are **not**
verified.
- S/MIME certificate chains are **not** built or validated against any
trust anchor; revocation (CRL/OCSP) is **not** checked.
- Authenticity of the records themselves is delegated to the
validating resolver via the DNSSEC `AD` flag (see
`dnssec_not_validated`). Run the checker against a resolver you
trust to perform DNSSEC validation.
Treat a green report as "the record is well-formed and DNSSEC-signed",
not as "the key is trustworthy".
## Tests run
All findings are tagged by severity (`info` / `warn` / `crit`) so the
rule engine can fold them into a single `CheckState`.
### DNS (both record types)
| Code | Severity | What it catches |
| --- | --- | --- |
| `dns_query_failed` | crit | The resolver returned an error or did not answer. |
| `dns_no_record` | crit | The authoritative answer has no record at the expected owner. |
| `dnssec_not_validated` | crit / warn | The validating resolver did not set `AD`. RFC 7929/8162 mandate DNSSEC; the severity is configurable via `requireDNSSEC`. |
| `dns_record_mismatch` | warn | The record returned by DNS differs from the one declared in the service (typically a stale zone on the authoritative servers). |
| `owner_hash_mismatch` | crit | Record owner-name first label is not `sha256(localpart)[:28]`; mail clients will never find it. |
### OpenPGP-specific (RFC 7929)
| Code | Severity | What it catches |
| --- | --- | --- |
| `pgp_parse_error` | crit | Malformed base64 or OpenPGP packet stream. |
| `pgp_no_entity` | crit | Record decoded but carries no valid entity. |
| `pgp_primary_revoked` | crit | Primary key has a revocation signature. |
| `pgp_primary_expired` | crit | Self-signature expired; clients will refuse to encrypt. |
| `pgp_primary_expiring_soon` | warn | Expires within the `certExpiryWarnDays` window (default 30). |
| `pgp_weak_algorithm` | warn | Uses DSA / ElGamal (phase-out). |
| `pgp_weak_key_size` | crit / warn | RSA below 2048 bits is critical, 2048-3071 is a warn. |
| `pgp_no_encryption_subkey` | crit | No active key in the entity advertises encryption capability. |
| `pgp_no_identity` | warn | No self-signed User ID. |
| `pgp_uid_mismatch` | info | None of the UIDs reference `<username@…>`. |
| `pgp_multiple_entities` | warn | Record carries more than one entity (RFC 7929 recommends one). |
| `pgp_record_too_large` | warn | Raw key > 4 KiB; forces UDP→TCP fallback on every lookup. |
### SMIMEA-specific (RFC 8162)
| Code | Severity | What it catches |
| --- | --- | --- |
| `smimea_bad_usage` / `_selector` / `_match_type` | crit | Field outside the allowed range. |
| `smimea_cert_parse_error` | crit | Hex-encoded blob is not a valid X.509 certificate / SPKI. |
| `smimea_cert_expired` / `_not_yet_valid` | crit | `notBefore` / `notAfter` gate the current time out. |
| `smimea_cert_expiring_soon` | warn | Within the `certExpiryWarnDays` window. |
| `smimea_no_email_protection_eku` | crit / warn | Missing `emailProtection` EKU (RFC 8550/8551 agents will reject). |
| `smimea_missing_key_usage` | warn | Neither `digitalSignature` nor `keyEncipherment` key-usage is set. |
| `smimea_email_mismatch` | info | No email SAN starts with `<username>@`. |
| `smimea_weak_signature_algorithm` | crit | MD5 / SHA-1 based signature. |
| `smimea_weak_key_size` | crit / warn | RSA < 2048 / 3072 bits. |
| `smimea_self_signed` | info | Self-signed certificate paired with PKIX-EE usage. |
| `smimea_hash_only` | info | Matching-type 1/2 only carries a digest; certificate can't be inspected. |
## Options
| Id | Type | Default | Description |
| --- | --- | --- | --- |
| `resolver` | string | *(system)* | Validating resolver to query; comma-separated list accepted. |
| `certExpiryWarnDays` | number | 30 | Raise an `expiring_soon` warning within this window. |
| `requireDNSSEC` | bool | true | When false, missing AD is a warn instead of crit. |
| `requireEmailProtection` | bool | true | When false, missing `emailProtection` EKU is a warn instead of crit. |
Auto-filled by the host: `domain_name`, `subdomain`, `service`,
`service_type`.
## Running
```bash
# Plugin (loaded by happyDomain at startup)
make plugin
# Standalone HTTP server
make && ./checker-email-keys -listen :8080
```

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 (p *emailKeyProvider) 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)

17
checker/provider.go Normal file
View file

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

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.StatusUnknown,
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.StatusUnknown {
t.Fatalf("PGP rule on SMIMEA kind should yield single Unknown 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"`
}

19
go.mod Normal file
View file

@ -0,0 +1,19 @@
module git.happydns.org/checker-email-keys
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.4.0
github.com/ProtonMail/go-crypto v1.1.0-alpha.0
github.com/miekg/dns v1.1.72
)
require (
github.com/cloudflare/circl v1.3.7 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

22
go.sum Normal file
View file

@ -0,0 +1,22 @@
git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs=
git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/ProtonMail/go-crypto v1.1.0-alpha.0 h1:nHGfwXmFvJrSR9xu8qL7BkO4DqTHXE9N5vPhgY2I+j0=
github.com/ProtonMail/go-crypto v1.1.0-alpha.0/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=

27
main.go Normal file
View file

@ -0,0 +1,27 @@
package main
import (
"flag"
"log"
emailkeys "git.happydns.org/checker-email-keys/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
// Version is the standalone binary's version. Override with:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
emailkeys.Version = Version
srv := server.New(emailkeys.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

22
plugin/plugin.go Normal file
View file

@ -0,0 +1,22 @@
// Command plugin is the happyDomain plugin entrypoint for the
// OPENPGPKEY/SMIMEA checker. Built as a Go plugin and loaded at runtime
// by happyDomain.
package main
import (
emailkeys "git.happydns.org/checker-email-keys/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the plugin's version, meant to be overridden by CI:
//
// go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-email-keys.so ./plugin
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading
// the .so file.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
emailkeys.Version = Version
prvd := emailkeys.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}