Initial commit

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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-openpgpkey
checker-openpgpkey.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-openpgpkey .
FROM scratch
COPY --from=builder /checker-openpgpkey /checker-openpgpkey
EXPOSE 8080
ENTRYPOINT ["/checker-openpgpkey"]

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-openpgpkey
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-openpgpkey
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.

128
README.md Normal file
View file

@ -0,0 +1,128 @@
# checker-openpgpkey
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. |
## Why a bespoke checker instead of a third-party testsuite?
There is no canonical "OPENPGPKEY / SMIMEA testsuite" in Go or as a
self-hostable online service:
- `ldns-dane` (NLnet Labs) validates DANE-TLSA and handles SMIMEA only
shallowly (it parses the record without deep certificate checks).
- `hokey` (Paul Wouters) queries OPENPGPKEY but does not validate the
key material.
- Online DANE validators (e.g. `dane.sys4.de`, `has-tls-rpt.com`) focus
on SMTP DANE-TLSA, not email-identity records.
The heavy lifting here is standard Go parsing:
- `github.com/ProtonMail/go-crypto/openpgp` (maintained fork of the
deprecated `golang.org/x/crypto/openpgp`) for OpenPGP packet parsing,
UIDs, subkeys, revocations, key-lifetime self-signatures.
- `crypto/x509` for SMIMEA certificate parsing, validity window, EKU,
key-usage, signature-algorithm and key-size checks.
- `github.com/miekg/dns` for the DNS+EDNS0+DO query and the `AD` flag
read-back used as the DNSSEC-validation signal.
## 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-openpgpkey -listen :8080
```
## HTML report
The report renders as a self-contained HTML document intended for
embedding in an `<iframe>` (the same contract as the other happyDomain
checkers). It is organised as:
1. **Header**: status badge, queried owner name, resolver used, DNSSEC
flag.
2. **Most common issues (fix these first)**: remediation cards shown
*only* when a matching finding was emitted. Each card carries the
concrete shell commands / zone-file snippets the user needs.
3. **OpenPGP key / SMIMEA record**: structured details for the
parsed material (fingerprint, UIDs, subkeys, cert subject/issuer,
EKU/KU flags, …).
4. **Findings**: the full table of per-finding code / severity /
message / fix hints, sorted by severity.

858
checker/collect.go Normal file
View file

@ -0,0 +1,858 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
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 {
sev := SeverityCrit
if ans.Rcode == dns.RcodeNameError {
sev = SeverityCrit
}
data.Findings = append(data.Findings, Finding{
Code: CodeDNSNoRecord,
Severity: sev,
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 ""
}

107
checker/definition.go Normal file
View file

@ -0,0 +1,107 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
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,
},
}
}

127
checker/dns.go Normal file
View file

@ -0,0 +1,127 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
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.Split(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"}