Initial commit

This commit is contained in:
nemunaire 2026-04-23 12:13:33 +07:00
commit 19296f4188
18 changed files with 2562 additions and 0 deletions

2
.gitignore vendored Normal file
View file

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

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-email-keys .
FROM scratch
COPY --from=builder /checker-email-keys /checker-email-keys
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.

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
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 clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -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) .
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.

89
README.md Normal file
View file

@ -0,0 +1,89 @@
# 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
## 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
```

848
checker/collect.go Normal file
View file

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

101
checker/definition.go Normal file
View file

@ -0,0 +1,101 @@
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 sdk.NewServer via the provider's
// CheckerDefinitionProvider implementation.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "openpgpkey-smimea",
Name: "OPENPGPKEY & SMIMEA",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{
ServiceOpenPGP,
ServiceSMimeCert,
},
},
ObservationKeys: []sdk.ObservationKey{ObservationKey},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionResolver,
Type: "string",
Label: "DNS resolver",
Placeholder: "1.1.1.1",
Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.",
},
{
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",
Description: "When enabled, a non-DNSSEC-validated lookup is reported as critical (otherwise as warning). RFC 7929 and RFC 8162 mandate DNSSEC.",
Default: true,
},
{
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,
},
},
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: []sdk.CheckRule{Rule()},
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 12 * time.Hour,
},
}
}

121
checker/dns.go Normal file
View file

@ -0,0 +1,121 @@
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
// 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 {
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)
}

171
checker/interactive.go Normal file
View file

@ -0,0 +1,171 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm implements sdk.CheckerInteractive. 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 sdk.CheckerInteractive. 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) + "." + strings.TrimPrefix(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 {
var f float64
if _, err := fmt.Sscanf(s, "%f", &f); err != nil {
return fallback
}
return f
}
// Compile-time assertion that the provider implements the optional interface.
var _ sdk.CheckerInteractive = (*emailKeyProvider)(nil)

23
checker/provider.go Normal file
View file

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

674
checker/report.go Normal file
View file

@ -0,0 +1,674 @@
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
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)
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) 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"
}
// Sort findings by severity (crit first) for stable display.
findings := make([]Finding, len(d.Findings))
copy(findings, d.Findings)
sort.SliceStable(findings, func(i, j int) bool {
return severityRank(findings[i].Severity) > severityRank(findings[j].Severity)
})
for _, f := range findings {
rd.Findings = append(rd.Findings, findingRow{
Code: f.Code, Severity: string(f.Severity), Message: f.Message, Fix: f.Fix,
})
switch f.Severity {
case SeverityCrit:
rd.CritCount++
case SeverityWarn:
rd.WarnCount++
case SeverityInfo:
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"
}
if d.Kind == KindOpenPGPKey && d.OpenPGP != nil {
rd.OpenPGP = buildOpenPGPView(d.OpenPGP)
}
if d.Kind == KindSMIMEA && d.SMIMEA != nil {
rd.SMIMEA = buildSMIMEAView(d.SMIMEA)
}
rd.Remediations = buildRemediations(d)
return rd
}
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 detects the most common failure scenarios and
// surfaces a focused, user-actionable card for each. Only matching
// issues produce a remediation; a clean run shows none.
func buildRemediations(d *EmailKeyData) []remediation {
var out []remediation
byCode := map[string]Finding{}
for _, f := range d.Findings {
// Keep the first (most severe after sort) finding per code.
if _, ok := byCode[f.Code]; !ok {
byCode[f.Code] = f
}
}
pick := func(code, title, body string) {
if _, ok := byCode[code]; !ok {
return
}
out = append(out, remediation{Title: title, Body: template.HTML(body)})
}
pick(CodeDNSNoRecord,
"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(CodeDNSNotSecure,
"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(CodeOwnerHashMismatch,
"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(CodePGPExpired,
"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(CodePGPRevoked,
"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(CodePGPNoEncryption,
"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(CodePGPWeakKeySize,
"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(CodeSMIMEACertExpired,
"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(CodeSMIMEANoEmailProtection,
"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(CodeSMIMEAWeakSignatureAlg,
"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(CodeSMIMEABadUsage,
"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(CodeSMIMEAHashOnly,
"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 severityRank(s Severity) int {
switch s {
case SeverityCrit:
return 3
case SeverityWarn:
return 2
case SeverityInfo:
return 1
}
return 0
}
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">
<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}}
<p class="note" style="margin-top:.6rem">Collected at {{.CollectedAt}}</p>
</div>
</body>
</html>`))

114
checker/rule.go Normal file
View file

@ -0,0 +1,114 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the single aggregation rule for this checker. It folds
// every finding produced by Collect into a CheckState whose status is
// the worst severity seen.
func Rule() sdk.CheckRule {
return &emailKeyRule{}
}
type emailKeyRule struct{}
func (r *emailKeyRule) Name() string { return "openpgpkey_smimea_check" }
func (r *emailKeyRule) Description() string {
return "Validates a DNS-published OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162), running DNSSEC, record-hash, parse, expiration, algorithm-strength, and S/MIME EKU checks."
}
func (r *emailKeyRule) 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",
}}
}
return []sdk.CheckState{evaluate(&data)}
}
// evaluate folds findings into a CheckState. The status is the highest
// severity observed: any Crit makes the whole result Crit, any Warn
// makes it Warn, otherwise Info/OK.
func evaluate(data *EmailKeyData) sdk.CheckState {
var crit, warn, info int
var firstCrit, firstWarn, firstInfo string
for _, f := range data.Findings {
switch f.Severity {
case SeverityCrit:
crit++
if firstCrit == "" {
firstCrit = f.Message
}
case SeverityWarn:
warn++
if firstWarn == "" {
firstWarn = f.Message
}
case SeverityInfo:
info++
if firstInfo == "" {
firstInfo = f.Message
}
}
}
status := sdk.StatusOK
msg := summariseHealthy(data)
code := "openpgpkey_ok"
switch {
case crit > 0:
status = sdk.StatusCrit
msg = firstCrit
code = "openpgpkey_crit"
case warn > 0:
status = sdk.StatusWarn
msg = firstWarn
code = "openpgpkey_warn"
case info > 0:
status = sdk.StatusInfo
msg = firstInfo
code = "openpgpkey_info"
}
meta := map[string]any{
"kind": data.Kind,
"queried": data.QueriedOwner,
"record_count": data.RecordCount,
"findings": data.Findings,
}
if data.DNSSECSecure != nil {
meta["dnssec_secure"] = *data.DNSSECSecure
}
return sdk.CheckState{
Status: status,
Message: msg,
Code: code,
Subject: data.QueriedOwner,
Meta: meta,
}
}
func summariseHealthy(data *EmailKeyData) string {
switch data.Kind {
case KindOpenPGPKey:
if data.OpenPGP != nil && data.OpenPGP.Fingerprint != "" {
return fmt.Sprintf("OPENPGPKEY %s published and valid (fingerprint %s)", data.QueriedOwner, data.OpenPGP.Fingerprint)
}
return fmt.Sprintf("OPENPGPKEY %s published and valid", data.QueriedOwner)
case KindSMIMEA:
if data.SMIMEA != nil && data.SMIMEA.Certificate != nil {
return fmt.Sprintf("SMIMEA %s valid (subject %s)", data.QueriedOwner, data.SMIMEA.Certificate.Subject)
}
return fmt.Sprintf("SMIMEA %s published and valid", data.QueriedOwner)
}
return "Record validated"
}

239
checker/types.go Normal file
View file

@ -0,0 +1,239 @@
// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for
// happyDomain. It runs a comprehensive testsuite on the DNS-published
// OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162) corresponding
// to an abstract.OpenPGP or abstract.SMimeCert service, and turns the
// results into structured findings + a remediation-oriented HTML report.
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
)
// Severity classifies a finding emitted by the checker.
type Severity string
const (
SeverityInfo Severity = "info"
SeverityWarn Severity = "warn"
SeverityCrit Severity = "crit"
)
// Finding codes surfaced by the checker. These strings are stable; the
// UI keys remediation templates off them.
const (
// DNS-level.
CodeDNSQueryFailed = "dns_query_failed"
CodeDNSNoRecord = "dns_no_record"
CodeDNSRecordMismatch = "dns_record_mismatch"
CodeDNSNotSecure = "dnssec_not_validated"
CodeOwnerHashMismatch = "owner_hash_mismatch"
// OpenPGP.
CodePGPParseError = "pgp_parse_error"
CodePGPNoEntity = "pgp_no_entity"
CodePGPRevoked = "pgp_primary_revoked"
CodePGPExpired = "pgp_primary_expired"
CodePGPExpiringSoon = "pgp_primary_expiring_soon"
CodePGPWeakAlgorithm = "pgp_weak_algorithm"
CodePGPWeakKeySize = "pgp_weak_key_size"
CodePGPNoEncryption = "pgp_no_encryption_subkey"
CodePGPNoIdentity = "pgp_no_identity"
CodePGPUIDMismatch = "pgp_uid_mismatch"
CodePGPMultipleEntities = "pgp_multiple_entities"
CodePGPRecordTooLarge = "pgp_record_too_large"
// SMIMEA.
CodeSMIMEABadUsage = "smimea_bad_usage"
CodeSMIMEABadSelector = "smimea_bad_selector"
CodeSMIMEABadMatchType = "smimea_bad_match_type"
CodeSMIMEACertParseError = "smimea_cert_parse_error"
CodeSMIMEACertExpired = "smimea_cert_expired"
CodeSMIMEACertExpiringSoon = "smimea_cert_expiring_soon"
CodeSMIMEACertNotYetValid = "smimea_cert_not_yet_valid"
CodeSMIMEANoEmailProtection = "smimea_no_email_protection_eku"
CodeSMIMEAEmailMismatch = "smimea_email_mismatch"
CodeSMIMEAWeakKeySize = "smimea_weak_key_size"
CodeSMIMEAWeakSignatureAlg = "smimea_weak_signature_algorithm"
CodeSMIMEANoKeyUsage = "smimea_missing_key_usage"
CodeSMIMEAChainUntrusted = "smimea_chain_untrusted"
CodeSMIMEASelfSigned = "smimea_self_signed"
CodeSMIMEAHashOnly = "smimea_hash_only"
)
// Finding describes a single observation produced while running the
// testsuite.
type Finding struct {
Code string `json:"code"`
Severity Severity `json:"severity"`
Message string `json:"message"`
// Fix carries a short, user-facing hint describing how to address the
// issue. The HTML report falls back on generic Fix text keyed by Code
// when this field is empty.
Fix string `json:"fix,omitempty"`
}
// EmailKeyData is the observation payload written under ObservationKey.
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"`
// 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"`
// 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"`
Findings []Finding `json:"findings"`
CollectedAt time.Time `json:"collected_at"`
}
// OpenPGPInfo summarises the OpenPGP key observed in the record.
type OpenPGPInfo struct {
// 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"`
// 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 {
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"`
}
// 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.1.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.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y=
git.happydns.org/checker-sdk-go v1.1.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"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// 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
server := sdk.NewServer(emailkeys.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

21
plugin/plugin.go Normal file
View file

@ -0,0 +1,21 @@
// 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
return emailkeys.Definition(), emailkeys.Provider(), nil
}