From 5090ab5e6c1874384dd771a9ac56850798f4a7ef Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Apr 2026 12:13:33 +0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + Dockerfile | 15 + LICENSE | 21 ++ Makefile | 28 ++ NOTICE | 31 ++ README.md | 107 ++++++ checker/collect.go | 566 +++++++++++++++++++++++++++++++ checker/collect_test.go | 219 ++++++++++++ checker/definition.go | 80 +++++ checker/dns.go | 132 ++++++++ checker/dns_test.go | 50 +++ checker/interactive.go | 175 ++++++++++ checker/provider.go | 17 + checker/report.go | 719 ++++++++++++++++++++++++++++++++++++++++ checker/rule.go | 109 ++++++ checker/rules_check.go | 300 +++++++++++++++++ checker/rules_dns.go | 78 +++++ checker/rules_pgp.go | 213 ++++++++++++ checker/rules_smimea.go | 231 +++++++++++++ checker/rules_test.go | 330 ++++++++++++++++++ checker/types.go | 219 ++++++++++++ go.mod | 19 ++ go.sum | 22 ++ main.go | 27 ++ plugin/plugin.go | 22 ++ 25 files changed, 3732 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/collect_test.go create mode 100644 checker/definition.go create mode 100644 checker/dns.go create mode 100644 checker/dns_test.go create mode 100644 checker/interactive.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/rule.go create mode 100644 checker/rules_check.go create mode 100644 checker/rules_dns.go create mode 100644 checker/rules_pgp.go create mode 100644 checker/rules_smimea.go create mode 100644 checker/rules_test.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41eb9b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-email-keys +checker-email-keys.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0dd6172 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-email-keys . + +FROM scratch +COPY --from=builder /checker-email-keys /checker-email-keys +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/checker-email-keys"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a2216c5 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-email-keys +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker test clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +test: + go test -tags standalone ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..935eb2b --- /dev/null +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f87d3c2 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# checker-email-keys + +DANE-Email posture checker for happyDomain. + +Runs a comprehensive testsuite on a domain's DNS-published OpenPGP key +(`OPENPGPKEY`, [RFC 7929][rfc7929]) or S/MIME certificate (`SMIMEA`, +[RFC 8162][rfc8162]) and renders an actionable HTML report whose top +block nudges the user toward the fix for the most common failure +scenarios. + +This checker binds to the happyDomain services: + +- `abstract.OpenPGP`: individual user's PGP key, owner-hashed below + `._openpgpkey.`. +- `abstract.SMimeCert`: user's S/MIME certificate, owner-hashed below + `._smimecert.`. + +[rfc7929]: https://www.rfc-editor.org/rfc/rfc7929 +[rfc8162]: https://www.rfc-editor.org/rfc/rfc8162 + +## Security scope + +This checker validates DNS publication and the structure/metadata of the +keys it finds. It does **not** cryptographically verify them: + +- OpenPGP signatures (self-signatures, third-party certifications, + revocations beyond the presence of a revocation packet) are **not** + verified. +- S/MIME certificate chains are **not** built or validated against any + trust anchor; revocation (CRL/OCSP) is **not** checked. +- Authenticity of the records themselves is delegated to the + validating resolver via the DNSSEC `AD` flag (see + `dnssec_not_validated`). Run the checker against a resolver you + trust to perform DNSSEC validation. + +Treat a green report as "the record is well-formed and DNSSEC-signed", +not as "the key is trustworthy". + +## Tests run + +All findings are tagged by severity (`info` / `warn` / `crit`) so the +rule engine can fold them into a single `CheckState`. + +### DNS (both record types) + +| Code | Severity | What it catches | +| --- | --- | --- | +| `dns_query_failed` | crit | The resolver returned an error or did not answer. | +| `dns_no_record` | crit | The authoritative answer has no record at the expected owner. | +| `dnssec_not_validated` | crit / warn | The validating resolver did not set `AD`. RFC 7929/8162 mandate DNSSEC; the severity is configurable via `requireDNSSEC`. | +| `dns_record_mismatch` | warn | The record returned by DNS differs from the one declared in the service (typically a stale zone on the authoritative servers). | +| `owner_hash_mismatch` | crit | Record owner-name first label is not `sha256(localpart)[:28]`; mail clients will never find it. | + +### OpenPGP-specific (RFC 7929) + +| Code | Severity | What it catches | +| --- | --- | --- | +| `pgp_parse_error` | crit | Malformed base64 or OpenPGP packet stream. | +| `pgp_no_entity` | crit | Record decoded but carries no valid entity. | +| `pgp_primary_revoked` | crit | Primary key has a revocation signature. | +| `pgp_primary_expired` | crit | Self-signature expired; clients will refuse to encrypt. | +| `pgp_primary_expiring_soon` | warn | Expires within the `certExpiryWarnDays` window (default 30). | +| `pgp_weak_algorithm` | warn | Uses DSA / ElGamal (phase-out). | +| `pgp_weak_key_size` | crit / warn | RSA below 2048 bits is critical, 2048-3071 is a warn. | +| `pgp_no_encryption_subkey` | crit | No active key in the entity advertises encryption capability. | +| `pgp_no_identity` | warn | No self-signed User ID. | +| `pgp_uid_mismatch` | info | None of the UIDs reference ``. | +| `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 `@`. | +| `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 +``` diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..2b715fe --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,566 @@ +package checker + +import ( + "bytes" + "context" + "crypto/dsa" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "strings" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// maxKeyMaterialBytes caps the decoded byte size of an OPENPGPKEY +// payload or an SMIMEA certificate before it is handed to the parser. +// Anything larger is rejected outright to keep parser costs bounded; a +// rule (e.g. RulePGPRecordTooLarge at 4 KiB) flags more conservative +// limits separately. 64 KiB is well above any legitimate OpenPGP key +// size while staying clear of pathological input. +const maxKeyMaterialBytes = 64 * 1024 + +// serviceBody is the common envelope for the two services. +type serviceBody struct { + Username string `json:"username,omitempty"` + OpenPGP *dns.OPENPGPKEY `json:"openpgpkey,omitempty"` + SMIMEA *dns.SMIMEA `json:"smimea,omitempty"` +} + +// Collect runs the DANE-email data gathering pipeline and returns an +// *EmailKeyData carrying raw facts (DNS outcome, parsed key / cert +// structure). Judgment, severity, fix hints, option-driven thresholds, +// is deferred to the rules. A non-nil error is returned only for +// unrecoverable input problems (missing options, unknown service type). +func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + svcMsg, err := serviceFromOptions(opts) + if err != nil { + return nil, err + } + + kind := kindForServiceType(svcMsg.Type) + if kind == "" { + return nil, fmt.Errorf("service type %q is not supported by this checker", svcMsg.Type) + } + + var body serviceBody + if err := json.Unmarshal(svcMsg.Service, &body); err != nil { + return nil, fmt.Errorf("decode service body: %w", err) + } + + originOpt, _ := sdk.GetOption[string](opts, "domain_name") + subdomainOpt, _ := sdk.GetOption[string](opts, "subdomain") + resolverOpt, _ := sdk.GetOption[string](opts, OptionResolver) + + origin := strings.TrimSuffix(firstNonEmpty(originOpt, svcMsg.Domain), ".") + if origin == "" { + return nil, fmt.Errorf("missing 'domain_name' option") + } + parent := joinSubdomain(subdomainOpt, origin) + + data := &EmailKeyData{ + Kind: kind, + Domain: dns.Fqdn(origin), + Subdomain: strings.TrimSuffix(subdomainOpt, "."), + Username: body.Username, + CollectedAt: time.Now().UTC(), + } + + prefix := OpenPGPKeyPrefix + if kind == KindSMIMEA { + prefix = SMIMEACertPrefix + } + expectedOwner, recordedOwner := computeOwner(body, prefix, parent) + data.ExpectedOwner = expectedOwner + data.QueriedOwner = firstNonEmpty(recordedOwner, expectedOwner) + + // Owner-name hash inputs: rules compare the two and decide. + if data.Username != "" { + data.ExpectedOwnerPrefix = ownerHashHex(data.Username) + data.ObservedOwnerPrefix = extractOwnerPrefix(data.QueriedOwner, prefix, parent) + } + + // DNS lookup + DNSSEC flag. + if data.QueriedOwner != "" { + servers := resolvers(resolverOpt) + qtype := dns.TypeOPENPGPKEY + if kind == KindSMIMEA { + qtype = dns.TypeSMIMEA + } + ans, err := lookup(ctx, servers, data.QueriedOwner, qtype) + if err != nil { + data.DNSQueryError = fmt.Sprintf("DNS lookup for %s %s failed: %v", dns.TypeToString[qtype], data.QueriedOwner, err) + } else { + data.Resolver = ans.Server + secure := ans.AD + data.DNSSECSecure = &secure + data.RecordCount = len(ans.Records) + + present := !(ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0) + data.DNSAnswerPresent = &present + + // Compare DNS-returned record bytes with the service-declared ones + // only when we actually have records to compare and a reference. + if present { + var match bool + switch { + case kind == KindOpenPGPKey && body.OpenPGP != nil: + match = anyOpenPGPMatches(ans.Records, body.OpenPGP) + data.DNSRecordMatchesService = &match + case kind == KindSMIMEA && body.SMIMEA != nil: + match = anySMIMEAMatches(ans.Records, body.SMIMEA) + data.DNSRecordMatchesService = &match + } + } + } + } + + // Parse the payload from the service body (so rules can evaluate even + // when the DNS lookup failed to reach the authoritative servers). + if kind == KindOpenPGPKey { + data.OpenPGP = analyzeOpenPGP(body) + } else { + data.SMIMEA = analyzeSMIMEA(body) + } + + return data, nil +} + +// serviceFromOptions pulls the "service" option out of the options map, +// accepting both the in-process plugin path (native Go value) and the +// HTTP path (JSON-decoded map[string]any). Normalising via a JSON +// round-trip keeps both paths working without importing the upstream +// type. +func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) { + v, ok := opts["service"] + if !ok { + return nil, fmt.Errorf("service option missing") + } + raw, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal service option: %w", err) + } + var svc serviceMessage + if err := json.Unmarshal(raw, &svc); err != nil { + return nil, fmt.Errorf("decode service option: %w", err) + } + // Fall back to the service_type option when the envelope doesn't + // carry _svctype (older hosts). + if svc.Type == "" { + if st, ok := sdk.GetOption[string](opts, "service_type"); ok { + svc.Type = st + } + } + return &svc, nil +} + +func kindForServiceType(t string) string { + switch t { + case ServiceOpenPGP: + return KindOpenPGPKey + case ServiceSMimeCert: + return KindSMIMEA + default: + return "" + } +} + +// ownerHashHex returns the RFC 7929 / 8162 label: hex(sha256(localpart)[:28]). +func ownerHashHex(username string) string { + sum := sha256.Sum256([]byte(username)) + return hex.EncodeToString(sum[:DANEOwnerHashSize]) +} + +// computeOwner derives the expected FQDN from the service body. It +// returns the expected-by-specification owner and, when the service +// body carries its own Hdr.Name, the recorded owner, so we can detect +// discrepancies between the two. +func computeOwner(body serviceBody, prefix, parent string) (expected, recorded string) { + if body.Username != "" { + expected = dns.Fqdn(ownerHashHex(body.Username) + "." + strings.TrimPrefix(prefix, "") + "." + strings.TrimSuffix(parent, ".")) + // Normalise: no double dots. + expected = strings.Replace(expected, "..", ".", -1) + } + switch { + case body.OpenPGP != nil && body.OpenPGP.Hdr.Name != "": + recorded = dns.Fqdn(body.OpenPGP.Hdr.Name) + case body.SMIMEA != nil && body.SMIMEA.Hdr.Name != "": + recorded = dns.Fqdn(body.SMIMEA.Hdr.Name) + } + return +} + +// extractOwnerPrefix pulls the leading label from an owner name of the +// form ._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 ".." just after the first label. + marker := "." + prefix + "." + if i := strings.Index(owner, marker); i > 0 { + return owner[:i] + } + return "" +} + +// anyOpenPGPMatches reports whether any of rrs carries the same public +// key bytes as ref. +func anyOpenPGPMatches(rrs []dns.RR, ref *dns.OPENPGPKEY) bool { + want := strings.TrimSpace(ref.PublicKey) + for _, rr := range rrs { + if r, ok := rr.(*dns.OPENPGPKEY); ok && strings.TrimSpace(r.PublicKey) == want { + return true + } + } + return false +} + +// anySMIMEAMatches reports whether any of rrs matches ref on (usage, +// selector, matching type, certificate bytes). +func anySMIMEAMatches(rrs []dns.RR, ref *dns.SMIMEA) bool { + want := strings.ToLower(strings.TrimSpace(ref.Certificate)) + for _, rr := range rrs { + r, ok := rr.(*dns.SMIMEA) + if !ok { + continue + } + if r.Usage == ref.Usage && r.Selector == ref.Selector && r.MatchingType == ref.MatchingType && + strings.ToLower(strings.TrimSpace(r.Certificate)) == want { + return true + } + } + return false +} + +// ── OpenPGP analysis ───────────────────────────────────────────────────────── + +// analyzeOpenPGP parses the OpenPGP key from the service record and +// returns a structured fact summary. When parsing fails, ParseError is +// populated and the rest of the fields hold whatever could be recovered. +func analyzeOpenPGP(body serviceBody) *OpenPGPInfo { + if body.OpenPGP == nil { + return &OpenPGPInfo{ParseError: "Service body has no OPENPGPKEY record."} + } + + encoded := body.OpenPGP.PublicKey + // Reject pathological payloads before allocating: the base64-decoded + // size is at most ceil(len(encoded)*3/4). + if len(encoded)/4*3 > maxKeyMaterialBytes { + return &OpenPGPInfo{ + RawSize: len(encoded) / 4 * 3, + ParseError: fmt.Sprintf("OPENPGPKEY payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes), + } + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return &OpenPGPInfo{ParseError: fmt.Sprintf("OPENPGPKEY record carries invalid base64: %v", err)} + } + if len(raw) > maxKeyMaterialBytes { + return &OpenPGPInfo{ + RawSize: len(raw), + ParseError: fmt.Sprintf("OPENPGPKEY payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes), + } + } + + info := &OpenPGPInfo{RawSize: len(raw)} + + entities, err := openpgp.ReadKeyRing(bytes.NewReader(raw)) + if err != nil || len(entities) == 0 { + if err == nil { + err = fmt.Errorf("no OpenPGP entity found") + } + info.ParseError = fmt.Sprintf("Cannot parse OpenPGP key: %v", err) + return info + } + + info.EntityCount = len(entities) + + ent := entities[0] + pub := ent.PrimaryKey + info.CreatedAt = pub.CreationTime + info.Fingerprint = strings.ToUpper(hex.EncodeToString(pub.Fingerprint)) + info.KeyID = fmt.Sprintf("%016X", pub.KeyId) + info.PrimaryAlgorithm = algorithmName(pub) + info.PrimaryBits = publicKeyBits(pub) + + for name := range ent.Identities { + info.UIDs = append(info.UIDs, name) + } + + if len(ent.Revocations) > 0 { + info.Revoked = true + } + + // Expiry on the primary key, derived from the self-signature. + now := time.Now() + if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil { + if selfSig.KeyLifetimeSecs != nil && *selfSig.KeyLifetimeSecs > 0 { + info.ExpiresAt = pub.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second) + } + } + + // UID vs username matching. + if len(ent.Identities) > 0 && body.Username != "" { + wantedLocal := strings.ToLower(body.Username) + matched := false + for name := range ent.Identities { + if strings.Contains(strings.ToLower(name), "<"+wantedLocal+"@") || + strings.Contains(strings.ToLower(name), wantedLocal+"@") { + matched = true + break + } + } + info.MatchesUsername = &matched + } + + // Subkeys + encryption capability. + for _, sk := range ent.Subkeys { + si := SubkeyInfo{ + Algorithm: algorithmName(sk.PublicKey), + Bits: publicKeyBits(sk.PublicKey), + CreatedAt: sk.PublicKey.CreationTime, + Revoked: len(sk.Revocations) > 0, + } + if sk.Sig != nil { + if sk.Sig.FlagsValid { + si.CanSign = sk.Sig.FlagSign + si.CanEncrypt = sk.Sig.FlagEncryptCommunications || sk.Sig.FlagEncryptStorage + si.CanAuth = sk.Sig.FlagAuthenticate + } + if sk.Sig.KeyLifetimeSecs != nil && *sk.Sig.KeyLifetimeSecs > 0 { + si.ExpiresAt = sk.PublicKey.CreationTime.Add(time.Duration(*sk.Sig.KeyLifetimeSecs) * time.Second) + } + } + info.Subkeys = append(info.Subkeys, si) + + if si.CanEncrypt && !si.Revoked && (si.ExpiresAt.IsZero() || si.ExpiresAt.After(now)) { + info.HasEncryptionCapability = true + } + } + // Primary can also be an encryption key if flagged so. + if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil && selfSig.FlagsValid && + (selfSig.FlagEncryptCommunications || selfSig.FlagEncryptStorage) && + !info.Revoked && (info.ExpiresAt.IsZero() || info.ExpiresAt.After(now)) { + info.HasEncryptionCapability = true + } + + return info +} + +func algorithmName(pub *packet.PublicKey) string { + switch pub.PubKeyAlgo { + case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSAEncryptOnly, packet.PubKeyAlgoRSASignOnly: + return "RSA" + case packet.PubKeyAlgoDSA: + return "DSA" + case packet.PubKeyAlgoElGamal: + return "ElGamal" + case packet.PubKeyAlgoECDH: + return "ECDH" + case packet.PubKeyAlgoECDSA: + return "ECDSA" + case packet.PubKeyAlgoEdDSA: + return "EdDSA" + case packet.PubKeyAlgoX25519: + return "X25519" + case packet.PubKeyAlgoX448: + return "X448" + case packet.PubKeyAlgoEd25519: + return "Ed25519" + case packet.PubKeyAlgoEd448: + return "Ed448" + default: + return fmt.Sprintf("algo-%d", pub.PubKeyAlgo) + } +} + +func publicKeyBits(pub *packet.PublicKey) int { + if pub == nil { + return 0 + } + switch k := pub.PublicKey.(type) { + case *rsa.PublicKey: + if k == nil || k.N == nil { + return 0 + } + return k.N.BitLen() + case *dsa.PublicKey: + if k == nil || k.P == nil { + return 0 + } + return k.P.BitLen() + case *ecdsa.PublicKey: + if k == nil || k.Params() == nil { + return 0 + } + return k.Params().BitSize + case ed25519.PublicKey: + return 256 + } + // Fallback to the packet's advertised length. + if n, err := pub.BitLength(); err == nil { + return int(n) + } + return 0 +} + +// ── SMIMEA analysis ────────────────────────────────────────────────────────── + +// analyzeSMIMEA parses the SMIMEA certificate and returns a structured +// fact summary. When parsing fails, ParseError is populated. +func analyzeSMIMEA(body serviceBody) *SMIMEAInfo { + if body.SMIMEA == nil { + return &SMIMEAInfo{ParseError: "Service body has no SMIMEA record."} + } + rec := body.SMIMEA + + info := &SMIMEAInfo{ + Usage: rec.Usage, + Selector: rec.Selector, + MatchingType: rec.MatchingType, + HashHex: strings.ToLower(rec.Certificate), + } + + // Matching types 1 and 2 only carry a digest; no certificate or SPKI + // to parse. Rules surface that; here we just stop. + if rec.MatchingType != 0 { + return info + } + + if len(rec.Certificate)/2 > maxKeyMaterialBytes { + info.ParseError = fmt.Sprintf("SMIMEA payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes) + return info + } + der, err := hex.DecodeString(rec.Certificate) + if err != nil || len(der) == 0 { + info.ParseError = fmt.Sprintf("Cannot decode certificate bytes: %v", err) + return info + } + if len(der) > maxKeyMaterialBytes { + info.ParseError = fmt.Sprintf("SMIMEA payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes) + return info + } + + // Selector 1 carries only a SubjectPublicKeyInfo; parse it that way. + if rec.Selector == 1 { + info.PublicKey = analyzeSPKI(der, info) + return info + } + + cert, err := x509.ParseCertificate(der) + if err != nil { + // Try a PEM fallback for robustness. + if block, _ := pem.Decode(der); block != nil && block.Type == "CERTIFICATE" { + cert, err = x509.ParseCertificate(block.Bytes) + } + } + if err != nil || cert == nil { + if err == nil { + err = fmt.Errorf("no certificate found") + } + info.ParseError = fmt.Sprintf("Cannot parse X.509 certificate: %v", err) + return info + } + + ci := &CertInfo{ + Subject: cert.Subject.String(), + Issuer: cert.Issuer.String(), + SerialHex: strings.ToUpper(hex.EncodeToString(cert.SerialNumber.Bytes())), + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + SignatureAlgorithm: cert.SignatureAlgorithm.String(), + PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(), + EmailAddresses: cert.EmailAddresses, + DNSNames: cert.DNSNames, + IsCA: cert.IsCA, + } + ci.IsSelfSigned = cert.Subject.String() == cert.Issuer.String() && cert.CheckSignatureFrom(cert) == nil + ci.PublicKeyBits = x509PublicKeyBits(cert.PublicKey) + + for _, eku := range cert.ExtKeyUsage { + if eku == x509.ExtKeyUsageEmailProtection { + ci.HasEmailProtectionEKU = true + } + } + if cert.KeyUsage&x509.KeyUsageDigitalSignature != 0 { + ci.HasDigitalSignature = true + } + if cert.KeyUsage&x509.KeyUsageKeyEncipherment != 0 { + ci.HasKeyEncipherment = true + } + + // Email-address / username pairing fact. + if body.Username != "" && len(cert.EmailAddresses) > 0 { + wantPrefix := strings.ToLower(body.Username) + "@" + matched := false + for _, e := range cert.EmailAddresses { + if strings.HasPrefix(strings.ToLower(e), wantPrefix) { + matched = true + break + } + } + ci.EmailMatchesUsername = &matched + } + + info.Certificate = ci + return info +} + +func analyzeSPKI(der []byte, info *SMIMEAInfo) *PubKeyInfo { + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + info.ParseError = fmt.Sprintf("Cannot parse SubjectPublicKeyInfo: %v", err) + return nil + } + pk := &PubKeyInfo{Bits: x509PublicKeyBits(pub)} + switch pub.(type) { + case *rsa.PublicKey: + pk.Algorithm = "RSA" + case *ecdsa.PublicKey: + pk.Algorithm = "ECDSA" + case ed25519.PublicKey: + pk.Algorithm = "Ed25519" + default: + pk.Algorithm = fmt.Sprintf("%T", pub) + } + return pk +} + +func x509PublicKeyBits(pub any) int { + switch k := pub.(type) { + case *rsa.PublicKey: + if k == nil || k.N == nil { + return 0 + } + return k.N.BitLen() + case *ecdsa.PublicKey: + if k == nil || k.Params() == nil { + return 0 + } + return k.Params().BitSize + case ed25519.PublicKey: + return 256 + } + return 0 +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..1ea3ac1 --- /dev/null +++ b/checker/collect_test.go @@ -0,0 +1,219 @@ +package checker + +import ( + "encoding/base64" + "encoding/hex" + "strings" + "testing" + + "github.com/miekg/dns" +) + +func TestOwnerHashHex(t *testing.T) { + // RFC 7929 worked example: the SHA-256 of "hugh" truncated to 28 + // bytes, hex-encoded. + got := ownerHashHex("hugh") + if len(got) != 56 { + t.Fatalf("len = %d, want 56", len(got)) + } + if got != strings.ToLower(got) { + t.Errorf("expected lowercase hex, got %q", got) + } + // Stable across calls. + if ownerHashHex("hugh") != got { + t.Error("ownerHashHex is not deterministic") + } + // Different inputs ⇒ different output. + if ownerHashHex("alice") == got { + t.Error("collisions across distinct inputs") + } +} + +func TestExtractOwnerPrefix(t *testing.T) { + cases := []struct { + owner, prefix, want string + }{ + {"abc123._openpgpkey.example.com.", "_openpgpkey", "abc123"}, + {"ABC123._OPENPGPKEY.example.com", "_openpgpkey", "abc123"}, + {"abc123._smimecert.example.com.", "_smimecert", "abc123"}, + {"example.com.", "_openpgpkey", ""}, + {"_openpgpkey.example.com.", "_openpgpkey", ""}, // no leading hash label + {"", "_openpgpkey", ""}, + } + for _, c := range cases { + got := extractOwnerPrefix(c.owner, c.prefix, "") + if got != c.want { + t.Errorf("extractOwnerPrefix(%q,%q) = %q, want %q", c.owner, c.prefix, got, c.want) + } + } +} + +func TestFirstNonEmpty(t *testing.T) { + if got := firstNonEmpty("", " ", "x", "y"); got != "x" { + t.Errorf("got %q, want x", got) + } + if got := firstNonEmpty("", "", ""); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestKindForServiceType(t *testing.T) { + cases := map[string]string{ + ServiceOpenPGP: KindOpenPGPKey, + ServiceSMimeCert: KindSMIMEA, + "abstract.Other": "", + "": "", + } + for in, want := range cases { + if got := kindForServiceType(in); got != want { + t.Errorf("kindForServiceType(%q) = %q, want %q", in, got, want) + } + } +} + +func TestComputeOwner(t *testing.T) { + body := serviceBody{Username: "alice"} + exp, rec := computeOwner(body, OpenPGPKeyPrefix, "example.com") + wantPrefix := ownerHashHex("alice") + "._openpgpkey.example.com." + if exp != wantPrefix { + t.Errorf("expected = %q, want %q", exp, wantPrefix) + } + if rec != "" { + t.Errorf("recorded = %q, want empty", rec) + } + + // With a record carrying its own owner. + body.OpenPGP = &dns.OPENPGPKEY{Hdr: dns.RR_Header{Name: "abc._openpgpkey.example.com."}} + _, rec = computeOwner(body, OpenPGPKeyPrefix, "example.com") + if rec != "abc._openpgpkey.example.com." { + t.Errorf("recorded = %q", rec) + } + + // Empty username yields empty expected owner. + exp, _ = computeOwner(serviceBody{}, OpenPGPKeyPrefix, "example.com") + if exp != "" { + t.Errorf("expected = %q, want empty", exp) + } +} + +func TestAnyOpenPGPMatches(t *testing.T) { + ref := &dns.OPENPGPKEY{PublicKey: "AAAA"} + rrs := []dns.RR{ + &dns.OPENPGPKEY{PublicKey: "BBBB"}, + &dns.OPENPGPKEY{PublicKey: " AAAA "}, // trims whitespace + } + if !anyOpenPGPMatches(rrs, ref) { + t.Error("expected match") + } + if anyOpenPGPMatches([]dns.RR{&dns.OPENPGPKEY{PublicKey: "ZZZZ"}}, ref) { + t.Error("unexpected match") + } + // Non-OPENPGPKEY RRs are skipped silently. + if anyOpenPGPMatches([]dns.RR{&dns.A{}}, ref) { + t.Error("non-OPENPGPKEY RR matched") + } +} + +func TestAnySMIMEAMatches(t *testing.T) { + ref := &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "DEADBEEF"} + rrs := []dns.RR{ + &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "deadbeef"}, + } + if !anySMIMEAMatches(rrs, ref) { + t.Error("expected case-insensitive match") + } + rrs = []dns.RR{&dns.SMIMEA{Usage: 1, Selector: 0, MatchingType: 0, Certificate: "deadbeef"}} + if anySMIMEAMatches(rrs, ref) { + t.Error("usage mismatch should not match") + } +} + +func TestAnalyzeOpenPGP_NoRecord(t *testing.T) { + got := analyzeOpenPGP(serviceBody{}) + if got == nil || got.ParseError == "" { + t.Fatalf("expected ParseError, got %+v", got) + } +} + +func TestAnalyzeOpenPGP_BadBase64(t *testing.T) { + body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: "!!! not base64 !!!"}} + got := analyzeOpenPGP(body) + if !strings.Contains(got.ParseError, "invalid base64") { + t.Errorf("ParseError = %q", got.ParseError) + } +} + +func TestAnalyzeOpenPGP_OversizePayload(t *testing.T) { + // A base64 payload whose decoded size would exceed the cap. + raw := make([]byte, maxKeyMaterialBytes+1024) + body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: base64.StdEncoding.EncodeToString(raw)}} + got := analyzeOpenPGP(body) + if !strings.Contains(got.ParseError, "exceeds") { + t.Errorf("expected size-limit ParseError, got %q", got.ParseError) + } + // And we never tried to actually parse it as a keyring. + if got.EntityCount != 0 { + t.Errorf("EntityCount = %d, want 0", got.EntityCount) + } +} + +func TestAnalyzeOpenPGP_GarbageBytes(t *testing.T) { + // Valid base64, but not a valid OpenPGP packet stream. + body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: base64.StdEncoding.EncodeToString([]byte("not a key"))}} + got := analyzeOpenPGP(body) + if got.ParseError == "" { + t.Error("expected ParseError for garbage payload") + } + if got.RawSize == 0 { + t.Error("RawSize should be set even on parse failure") + } +} + +func TestAnalyzeSMIMEA_NoRecord(t *testing.T) { + got := analyzeSMIMEA(serviceBody{}) + if got == nil || got.ParseError == "" { + t.Fatalf("expected ParseError, got %+v", got) + } +} + +func TestAnalyzeSMIMEA_DigestOnly(t *testing.T) { + body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 1, Certificate: "abcd"}} + got := analyzeSMIMEA(body) + if got.ParseError != "" { + t.Errorf("digest-only should not error: %q", got.ParseError) + } + if got.Certificate != nil || got.PublicKey != nil { + t.Error("digest-only should not populate Certificate/PublicKey") + } + if got.HashHex != "abcd" { + t.Errorf("HashHex = %q", got.HashHex) + } +} + +func TestAnalyzeSMIMEA_BadHex(t *testing.T) { + body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "ZZZZ"}} + got := analyzeSMIMEA(body) + if got.ParseError == "" { + t.Error("expected ParseError for invalid hex") + } +} + +func TestAnalyzeSMIMEA_OversizePayload(t *testing.T) { + huge := strings.Repeat("ab", maxKeyMaterialBytes+1024) + body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: huge}} + got := analyzeSMIMEA(body) + if !strings.Contains(got.ParseError, "exceeds") { + t.Errorf("expected size-limit ParseError, got %q", got.ParseError) + } +} + +func TestAnalyzeSMIMEA_NotACertificate(t *testing.T) { + body := serviceBody{SMIMEA: &dns.SMIMEA{ + Usage: 3, Selector: 0, MatchingType: 0, + Certificate: hex.EncodeToString([]byte("not a DER cert")), + }} + got := analyzeSMIMEA(body) + if got.ParseError == "" { + t.Error("expected ParseError for non-cert bytes") + } +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..06f4e7b --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,80 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version defaults to "built-in"; standalone and plugin builds override +// it via -ldflags "-X .../checker.Version=...". +var Version = "built-in" + +// Option ids. +const ( + OptionResolver = "resolver" + OptionCertExpiryWarnDays = "certExpiryWarnDays" + OptionRequireDNSSEC = "requireDNSSEC" + OptionRequireEmailProtection = "requireEmailProtection" +) + +// Definition is the package-level helper returned to the host by the +// plugin entrypoint and used by server.New via the provider's +// CheckerDefinitionProvider implementation. +func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "openpgpkey-smimea", + Name: "OPENPGPKEY & SMIMEA", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{ + ServiceOpenPGP, + ServiceSMimeCert, + }, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKey}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: OptionResolver, + Type: "string", + Label: "DNS resolver", + Placeholder: "1.1.1.1", + Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.", + }, + }, + DomainOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Zone origin", + AutoFill: sdk.AutoFillDomainName, + }, + { + Id: "subdomain", + Label: "Subdomain", + AutoFill: sdk.AutoFillSubdomain, + }, + }, + ServiceOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "service", + Label: "Service", + AutoFill: sdk.AutoFillService, + }, + { + Id: "service_type", + Label: "Service type", + AutoFill: sdk.AutoFillServiceType, + Hide: true, + }, + }, + }, + Rules: Rules(), + Interval: &sdk.CheckIntervalSpec{ + Min: 1 * time.Hour, + Max: 7 * 24 * time.Hour, + Default: 12 * time.Hour, + }, + } +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..07e0f08 --- /dev/null +++ b/checker/dns.go @@ -0,0 +1,132 @@ +package checker + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/miekg/dns" +) + +// dnsTimeout is the per-query deadline used by every helper here. +const dnsTimeout = 5 * time.Second + +// maxAnswerRecords caps how many answer RRs of the requested type are +// retained from a single DNS response. A DANE owner serving more than a +// handful of keys is already abnormal; bounding the count keeps later +// per-record work (parsing, comparison) from blowing up if a zone (or a +// hostile resolver) returns a pathological answer set. +const maxAnswerRecords = 64 + +// dnsLookupAnswer is the subset of a DNS answer this checker cares about. +type dnsLookupAnswer struct { + // Records are the answer records of the requested type. + Records []dns.RR + // AD reports whether the response header has the Authenticated Data + // flag set, i.e. the validating resolver confirmed DNSSEC. + AD bool + // Rcode is the response code from the answering resolver. + Rcode int + // Server is the address of the resolver that answered. + Server string +} + +// resolvers returns the list of resolver addresses to try. If resolverOpt +// is non-empty it is parsed (comma-separated allowed) into a host:port +// list. Otherwise /etc/resolv.conf is read; if that fails we fall back to +// public validating resolvers. +func resolvers(resolverOpt string) []string { + if s := strings.TrimSpace(resolverOpt); s != "" { + var out []string + for part := range strings.SplitSeq(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if !strings.Contains(part, ":") { + part = net.JoinHostPort(part, "53") + } + out = append(out, part) + } + if len(out) > 0 { + return out + } + } + + if cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf"); err == nil && cfg != nil && len(cfg.Servers) > 0 { + out := make([]string, 0, len(cfg.Servers)) + for _, s := range cfg.Servers { + out = append(out, net.JoinHostPort(s, cfg.Port)) + } + return out + } + + // Fall back to known validating resolvers. + return []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"} +} + +// lookup queries qtype at owner against each resolver in order until one +// answers. The first resolver whose answer has a non-error Rcode wins. +// DNSSEC validation is requested via EDNS0 DO=1; the AD flag is read back +// from the response header. +func lookup(ctx context.Context, servers []string, owner string, qtype uint16) (*dnsLookupAnswer, error) { + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(owner), qtype) + m.SetEdns0(4096, true) + m.RecursionDesired = true + m.AuthenticatedData = true + + c := &dns.Client{Timeout: dnsTimeout} + + var lastErr error + for _, srv := range servers { + in, _, err := c.ExchangeContext(ctx, m, srv) + if err != nil { + lastErr = err + continue + } + if in == nil { + lastErr = fmt.Errorf("nil response from %s", srv) + continue + } + ans := &dnsLookupAnswer{ + Rcode: in.Rcode, + AD: in.AuthenticatedData, + Server: srv, + } + for _, rr := range in.Answer { + if rr.Header().Rrtype != qtype { + continue + } + if len(ans.Records) >= maxAnswerRecords { + break + } + ans.Records = append(ans.Records, rr) + } + if in.Rcode == dns.RcodeSuccess || in.Rcode == dns.RcodeNameError { + return ans, nil + } + lastErr = fmt.Errorf("rcode %s from %s", dns.RcodeToString[in.Rcode], srv) + } + if lastErr == nil { + lastErr = fmt.Errorf("no resolver available") + } + return nil, lastErr +} + +// joinSubdomain composes the FQDN of a subdomain within a zone. Both +// arguments are accepted in any of their canonical forms (trailing dot +// optional, empty subdomain allowed). +func joinSubdomain(subdomain, origin string) string { + origin = strings.TrimSuffix(origin, ".") + subdomain = strings.TrimSuffix(subdomain, ".") + if subdomain == "" || subdomain == "@" { + return dns.Fqdn(origin) + } + if strings.HasSuffix(subdomain, "."+origin) || subdomain == origin { + return dns.Fqdn(subdomain) + } + return dns.Fqdn(subdomain + "." + origin) +} diff --git a/checker/dns_test.go b/checker/dns_test.go new file mode 100644 index 0000000..763830a --- /dev/null +++ b/checker/dns_test.go @@ -0,0 +1,50 @@ +package checker + +import ( + "reflect" + "testing" +) + +func TestJoinSubdomain(t *testing.T) { + cases := []struct { + sub, origin, want string + }{ + {"", "example.com", "example.com."}, + {"@", "example.com", "example.com."}, + {"www", "example.com", "www.example.com."}, + {"www.", "example.com.", "www.example.com."}, + {"www.example.com", "example.com", "www.example.com."}, + {"example.com", "example.com", "example.com."}, + } + for _, c := range cases { + got := joinSubdomain(c.sub, c.origin) + if got != c.want { + t.Errorf("joinSubdomain(%q,%q) = %q, want %q", c.sub, c.origin, got, c.want) + } + } +} + +func TestResolvers_Explicit(t *testing.T) { + got := resolvers("1.1.1.1, 8.8.8.8:5353 ,") + want := []string{"1.1.1.1:53", "8.8.8.8:5353"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestResolvers_FallbackList(t *testing.T) { + // We don't trust /etc/resolv.conf to be absent in all CI environments, + // but the empty-input path must always return at least one resolver. + got := resolvers("") + if len(got) == 0 { + t.Fatal("expected at least one resolver") + } +} + +func TestMaxAnswerRecords_Constant(t *testing.T) { + // Sanity check: don't silently lower the cap to something useless + // without updating tests / behaviour. + if maxAnswerRecords < 8 { + t.Errorf("maxAnswerRecords=%d is suspiciously low", maxAnswerRecords) + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..a69039a --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,175 @@ +//go:build standalone + +package checker + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +// RenderForm implements server.Interactive. It exposes the minimal +// inputs needed to bootstrap a standalone OPENPGPKEY/SMIMEA check: an +// email address (the local part is hashed into the owner name) and a +// kind selector. The DNS resolver and severity-tuning options mirror +// the regular UserOpts so a human can override them on the form. +func (p *emailKeyProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "email", + Type: "string", + Label: "Email address", + Placeholder: "alice@example.com", + Description: "Address to look up. The local part is SHA-256-hashed per RFC 7929/8162; the domain part is the zone queried.", + Required: true, + }, + { + Id: "kind", + Type: "string", + Label: "Record kind", + Default: KindOpenPGPKey, + Choices: []string{KindOpenPGPKey, KindSMIMEA}, + }, + { + Id: OptionResolver, + Type: "string", + Label: "DNS resolver", + Placeholder: "1.1.1.1", + Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.", + }, + { + Id: OptionCertExpiryWarnDays, + Type: "number", + Label: "Expiry warning threshold (days)", + Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.", + Default: float64(30), + }, + { + Id: OptionRequireDNSSEC, + Type: "bool", + Label: "Require DNSSEC", + Default: true, + }, + { + Id: OptionRequireEmailProtection, + Type: "bool", + Label: "Require emailProtection EKU (SMIMEA only)", + Default: true, + }, + } +} + +// ParseForm implements server.Interactive. It validates the inputs, +// resolves the DNS record matching the requested kind, and returns the +// CheckerOptions that Collect expects, including a synthesised service +// envelope built from the live DNS answer. +func (p *emailKeyProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + email := strings.TrimSpace(r.FormValue("email")) + if email == "" { + return nil, fmt.Errorf("email is required") + } + at := strings.LastIndex(email, "@") + if at <= 0 || at == len(email)-1 { + return nil, fmt.Errorf("email %q must be of the form local@domain", email) + } + username := email[:at] + domain := strings.TrimSuffix(strings.ToLower(email[at+1:]), ".") + + kind := strings.TrimSpace(r.FormValue("kind")) + if kind == "" { + kind = KindOpenPGPKey + } + var ( + svcType string + prefix string + qtype uint16 + ) + switch kind { + case KindOpenPGPKey: + svcType = ServiceOpenPGP + prefix = OpenPGPKeyPrefix + qtype = dns.TypeOPENPGPKEY + case KindSMIMEA: + svcType = ServiceSMimeCert + prefix = SMIMEACertPrefix + qtype = dns.TypeSMIMEA + default: + return nil, fmt.Errorf("unknown kind %q (expected %q or %q)", kind, KindOpenPGPKey, KindSMIMEA) + } + + resolverOpt := strings.TrimSpace(r.FormValue(OptionResolver)) + owner := dns.Fqdn(ownerHashHex(username) + "." + prefix + "." + domain) + + ctx, cancel := context.WithTimeout(r.Context(), dnsTimeout*3) + defer cancel() + + ans, err := lookup(ctx, resolvers(resolverOpt), owner, qtype) + if err != nil { + return nil, fmt.Errorf("DNS lookup for %s %s failed: %w", dns.TypeToString[qtype], owner, err) + } + if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 { + return nil, fmt.Errorf("no %s record found at %s", dns.TypeToString[qtype], owner) + } + + body := serviceBody{Username: username} + switch kind { + case KindOpenPGPKey: + rr, ok := ans.Records[0].(*dns.OPENPGPKEY) + if !ok { + return nil, fmt.Errorf("unexpected record type %T at %s", ans.Records[0], owner) + } + body.OpenPGP = rr + case KindSMIMEA: + rr, ok := ans.Records[0].(*dns.SMIMEA) + if !ok { + return nil, fmt.Errorf("unexpected record type %T at %s", ans.Records[0], owner) + } + body.SMIMEA = rr + } + + bodyJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encode service body: %w", err) + } + svcMsg := serviceMessage{ + Type: svcType, + Domain: dns.Fqdn(domain), + Service: bodyJSON, + } + + opts := sdk.CheckerOptions{ + "service": svcMsg, + "service_type": svcType, + "domain_name": domain, + } + if resolverOpt != "" { + opts[OptionResolver] = resolverOpt + } + if v := strings.TrimSpace(r.FormValue(OptionCertExpiryWarnDays)); v != "" { + opts[OptionCertExpiryWarnDays] = parseFloatOr(v, 30) + } + opts[OptionRequireDNSSEC] = r.FormValue(OptionRequireDNSSEC) == "true" + opts[OptionRequireEmailProtection] = r.FormValue(OptionRequireEmailProtection) == "true" + + return opts, nil +} + +// parseFloatOr parses a decimal string, returning fallback on error. +func parseFloatOr(s string, fallback float64) float64 { + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return fallback + } + return f +} + +// Compile-time assertion that the provider implements the optional interface. +var _ server.Interactive = (*emailKeyProvider)(nil) diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..cf9e3e6 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,17 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Provider returns a new OPENPGPKEY/SMIMEA observation provider. +func Provider() sdk.ObservationProvider { + return &emailKeyProvider{} +} + +type emailKeyProvider struct{} + +// Key implements sdk.ObservationProvider. +func (p *emailKeyProvider) Key() sdk.ObservationKey { + return ObservationKey +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..c6cc7fd --- /dev/null +++ b/checker/report.go @@ -0,0 +1,719 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "sort" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// remediation is a single actionable hint shown in the report's +// "most common issues, fix these first" banner. Bodies are rendered +// with template.HTML so each remediation can ship its own markup +// (pre-formatted code snippets, lists, links). +type remediation struct { + Title string + Body template.HTML +} + +// findingRow models a single row in the full findings table. +type findingRow struct { + Code string + Severity string + Message string + Fix string +} + +// subkeyRow mirrors SubkeyInfo for the template, with pre-formatted +// times and a Capabilities string. +type subkeyRow struct { + Algorithm string + Bits int + Capabilities string + Created string + Expires string + Revoked bool +} + +// reportData is the template context. +type reportData struct { + Kind string + Headline string + Badge string // "ok" / "warn" / "fail" / "neutral" + QueriedOwner string + ExpectedOwner string + Resolver string + DNSSEC string // "secure" / "insecure" / "unknown" + RecordCount int + Username string + CollectedAt string + + OpenPGP *openPGPView + SMIMEA *smimeaView + + Remediations []remediation + Findings []findingRow + HasStates bool // true when rule states were threaded; gates the Findings section + CritCount int + WarnCount int + InfoCount int +} + +type openPGPView struct { + Fingerprint string + KeyID string + Algorithm string + Bits int + UIDs []string + Created string + Expires string + Revoked bool + Encrypt bool + Subkeys []subkeyRow + RawSize int + EntityCount int +} + +type smimeaView struct { + Usage string + Selector string + MatchingType string + HashOnly bool + HashHex string + Subject string + Issuer string + Serial string + NotBefore string + NotAfter string + SignatureAlgo string + KeyAlgo string + Bits int + Emails []string + DNSNames []string + EmailProtection bool + DigitalSignature bool + KeyEncipherment bool + SelfSigned bool + IsCA bool +} + +// GetHTMLReport implements sdk.CheckerHTMLReporter. +func (p *emailKeyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { + var data EmailKeyData + if err := json.Unmarshal(ctx.Data(), &data); err != nil { + return "", fmt.Errorf("unmarshal report data: %w", err) + } + + rd := buildReportData(&data, ctx.States()) + + var buf strings.Builder + if err := reportTemplate.Execute(&buf, rd); err != nil { + return "", fmt.Errorf("render report: %w", err) + } + return buf.String(), nil +} + +func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData { + rd := reportData{ + Kind: d.Kind, + QueriedOwner: d.QueriedOwner, + ExpectedOwner: d.ExpectedOwner, + Resolver: d.Resolver, + RecordCount: d.RecordCount, + Username: d.Username, + CollectedAt: d.CollectedAt.UTC().Format(time.RFC3339), + } + + switch { + case d.DNSSECSecure == nil: + rd.DNSSEC = "unknown" + case *d.DNSSECSecure: + rd.DNSSEC = "secure" + default: + rd.DNSSEC = "insecure" + } + + if d.Kind == KindOpenPGPKey && d.OpenPGP != nil { + rd.OpenPGP = buildOpenPGPView(d.OpenPGP) + } + if d.Kind == KindSMIMEA && d.SMIMEA != nil { + rd.SMIMEA = buildSMIMEAView(d.SMIMEA) + } + + // No rule states threaded through: data-only view. + if len(states) == 0 { + rd.Badge = "neutral" + rd.Headline = "Record details" + return rd + } + rd.HasStates = true + + // Pick the states we want on screen: drop bare StatusOK, and drop + // StatusInfo with no message (non-applicable rules). Keep anything + // else. + kept := make([]sdk.CheckState, 0, len(states)) + for _, s := range states { + if s.Status == sdk.StatusOK { + continue + } + if s.Status == sdk.StatusInfo && strings.TrimSpace(s.Message) == "" { + continue + } + kept = append(kept, s) + } + + // Sort by severity (crit first). + sort.SliceStable(kept, func(i, j int) bool { + return statusRank(kept[i].Status) > statusRank(kept[j].Status) + }) + for _, s := range kept { + rd.Findings = append(rd.Findings, findingRow{ + Code: s.Code, + Severity: severityLabel(s.Status), + Message: s.Message, + Fix: stateHint(s), + }) + switch s.Status { + case sdk.StatusCrit, sdk.StatusError: + rd.CritCount++ + case sdk.StatusWarn: + rd.WarnCount++ + case sdk.StatusInfo: + rd.InfoCount++ + } + } + + switch { + case rd.CritCount > 0: + rd.Badge = "fail" + rd.Headline = fmt.Sprintf("%d critical issue(s) found", rd.CritCount) + case rd.WarnCount > 0: + rd.Badge = "warn" + rd.Headline = fmt.Sprintf("%d warning(s)", rd.WarnCount) + case rd.InfoCount > 0: + rd.Badge = "neutral" + rd.Headline = "Informational findings" + default: + rd.Badge = "ok" + rd.Headline = "All checks passed" + } + + rd.Remediations = buildRemediations(d, kept) + + return rd +} + +func stateHint(s sdk.CheckState) string { + if s.Meta == nil { + return "" + } + if v, ok := s.Meta["hint"].(string); ok { + return v + } + return "" +} + +func severityLabel(st sdk.Status) string { + switch st { + case sdk.StatusCrit, sdk.StatusError: + return "crit" + case sdk.StatusWarn: + return "warn" + case sdk.StatusInfo: + return "info" + } + return "info" +} + +func statusRank(st sdk.Status) int { + switch st { + case sdk.StatusCrit, sdk.StatusError: + return 3 + case sdk.StatusWarn: + return 2 + case sdk.StatusInfo: + return 1 + } + return 0 +} + +func buildOpenPGPView(o *OpenPGPInfo) *openPGPView { + v := &openPGPView{ + Fingerprint: formatFingerprint(o.Fingerprint), + KeyID: o.KeyID, + Algorithm: o.PrimaryAlgorithm, + Bits: o.PrimaryBits, + UIDs: append([]string(nil), o.UIDs...), + Created: fmtTime(o.CreatedAt), + Expires: fmtTime(o.ExpiresAt), + Revoked: o.Revoked, + Encrypt: o.HasEncryptionCapability, + RawSize: o.RawSize, + EntityCount: o.EntityCount, + } + if v.Expires == "" { + v.Expires = "never" + } + sort.Strings(v.UIDs) + for _, sk := range o.Subkeys { + caps := subkeyCaps(sk) + v.Subkeys = append(v.Subkeys, subkeyRow{ + Algorithm: sk.Algorithm, + Bits: sk.Bits, + Capabilities: caps, + Created: fmtTime(sk.CreatedAt), + Expires: fmtTimeOrNever(sk.ExpiresAt), + Revoked: sk.Revoked, + }) + } + return v +} + +func buildSMIMEAView(s *SMIMEAInfo) *smimeaView { + v := &smimeaView{ + Usage: smimeaUsageName(s.Usage), + Selector: smimeaSelectorName(s.Selector), + MatchingType: smimeaMatchingTypeName(s.MatchingType), + HashOnly: s.MatchingType != 0, + HashHex: s.HashHex, + } + if s.Certificate != nil { + c := s.Certificate + v.Subject = c.Subject + v.Issuer = c.Issuer + v.Serial = c.SerialHex + v.NotBefore = fmtTime(c.NotBefore) + v.NotAfter = fmtTime(c.NotAfter) + v.SignatureAlgo = c.SignatureAlgorithm + v.KeyAlgo = c.PublicKeyAlgorithm + v.Bits = c.PublicKeyBits + v.Emails = append([]string(nil), c.EmailAddresses...) + v.DNSNames = append([]string(nil), c.DNSNames...) + v.EmailProtection = c.HasEmailProtectionEKU + v.DigitalSignature = c.HasDigitalSignature + v.KeyEncipherment = c.HasKeyEncipherment + v.SelfSigned = c.IsSelfSigned + v.IsCA = c.IsCA + } + if s.PublicKey != nil && v.KeyAlgo == "" { + v.KeyAlgo = s.PublicKey.Algorithm + v.Bits = s.PublicKey.Bits + } + return v +} + +// buildRemediations surfaces a focused, user-actionable card for each +// of the most common failure scenarios present in `states`. Only rules +// with a matching state produce a remediation; a clean run shows none. +func buildRemediations(d *EmailKeyData, states []sdk.CheckState) []remediation { + var out []remediation + + byCode := map[string]bool{} + for _, s := range states { + byCode[s.Code] = true + } + + pick := func(code, title, body string) { + if !byCode[code] { + return + } + out = append(out, remediation{Title: title, Body: template.HTML(body)}) + } + + pick(RuleDNSNoRecord, + "Publish the record in DNS", + fmt.Sprintf(`No %s record resolves at %s. Publish it in the zone and reload the authoritative servers.

+Quick checklist: +
    +
  1. Verify the owner name: sha256(localpart)[0:28] . %s . %s.
  2. +
  3. Confirm the record reached your signer by running dig +dnssec %s %s @<auth-ns>.
  4. +
  5. Wait for TTL expiry if the record was only recently published.
  6. +
`, + kindRRType(d.Kind), + template.HTMLEscapeString(d.QueriedOwner), + template.HTMLEscapeString(kindPrefix(d.Kind)), + template.HTMLEscapeString(strings.TrimSuffix(d.Domain, ".")), + kindRRType(d.Kind), + template.HTMLEscapeString(d.QueriedOwner))) + + pick(RuleDNSSECNotValidated, + "Enable DNSSEC on the zone", + `RFC 7929 and RFC 8162 only grant authority to the key/certificate when DNSSEC validates it. Without DNSSEC, an attacker on the network path can substitute the RR with their own material and impersonate the user.

+Steps: +
    +
  1. Sign the zone (Bind: dnssec-policy default; Knot: dnssec-signing: on; BIND/Knot-DNSSEC-policy or NSD+OpenDNSSEC, etc.).
  2. +
  3. Publish the DS record at the parent via your registrar.
  4. +
  5. Re-run this checker; the AD flag should light up.
  6. +
`) + + pick(RuleOwnerHashMismatch, + "Fix the record's owner-name hash", + `The record is published at a name whose first label does not equal hex(sha256(localpart))[:56] (28 bytes). Email agents will never find it because they compute the hash from the recipient address.

+Compute the correct name:
+
printf '%s' "local-part" | openssl dgst -sha256 | cut -c 1-56 | tr -d '\n' ; echo "._openpgpkey.domain.tld"
+Then republish the record at that owner name.`) + + pick(RulePGPPrimaryExpired, + "Renew the expired OpenPGP key", + `The primary key's self-signature expired, so clients will refuse to encrypt to it.
+
gpg --edit-key <fingerprint>
+gpg> expire
+... set a new expiration ...
+gpg> save
+gpg --export <fingerprint> | base64
+Paste the resulting base64 back into the OPENPGPKEY record.`) + + pick(RulePGPPrimaryRevoked, + "Publish a fresh, non-revoked key", + `The record carries a revoked primary key; clients will stop encrypting mail to this address as soon as they process the revocation.

+Either generate a new key pair and publish it here, or remove the OPENPGPKEY record so senders fall back to regular email (unencrypted).`) + + pick(RulePGPNoEncryption, + "Add an encryption subkey", + `Every non-revoked key in the record is marked sign-only. Mail clients will refuse to encrypt to this record.
+
gpg --edit-key <fingerprint>
+gpg> addkey
+... choose "RSA (encrypt only)" or "ECC (encrypt only)" ...
+gpg> save
+Re-export and republish.`) + + pick(RulePGPWeakKeySize, + "Rotate away from weak RSA keys", + `RSA below 2048 bits is considered broken. Generate a modern key and republish:
+
gpg --full-generate-key
+# choose 1 (RSA+RSA) with 3072/4096 bits,
+# or 9 (ECC+ECC) for Curve25519.
`) + + pick(RuleSMIMEACertExpired, + "Renew the S/MIME certificate", + `The certificate expired. Issue a fresh one and update the SMIMEA record:
+
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 > smimea.hex
+Splice the hex payload into the SMIMEA RDATA.`) + + pick(RuleSMIMEANoEmailProtect, + "Add the emailProtection EKU", + `Conforming S/MIME agents (RFC 8550/8551) only accept certificates whose Extended Key Usage advertises email protection (OID 1.3.6.1.5.5.7.3.4).

+In your openssl.cnf:
+
[usr_cert]
+extendedKeyUsage = emailProtection
+keyUsage = digitalSignature, keyEncipherment
+Re-issue the certificate, then update the SMIMEA record.`) + + pick(RuleSMIMEAWeakSigAlgorithm, + "Re-issue with a strong signature algorithm", + `MD5 and SHA-1 based signatures are collision-vulnerable and will be rejected by modern mail agents.

+Use at least SHA-256 when issuing:
+
openssl x509 -req -sha256 -in user.csr -CA ca.pem -CAkey ca.key -out user.crt
`) + + pick(RuleSMIMEABadUsage, + "Pick a valid SMIMEA usage", + `SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA) or 3 (DANE-EE). For self-hosted end-entity certificates, 3 (DANE-EE) is the right choice: it tells verifiers the record carries the exact certificate to trust and no chain validation is required.`) + + pick(RuleSMIMEAHashOnly, + "Consider publishing the full certificate", + `Matching types 1 (SHA-256) and 2 (SHA-512) only transport a digest. Consumers cannot extract the certificate from DNS and must obtain it through a side channel. Matching type 0 (Full) avoids that round trip and is the most interoperable option.`) + + return out +} + +func smimeaUsageName(u uint8) string { + switch u { + case 0: + return "0 PKIX-TA" + case 1: + return "1 PKIX-EE" + case 2: + return "2 DANE-TA" + case 3: + return "3 DANE-EE" + } + return fmt.Sprintf("%d (unknown)", u) +} +func smimeaSelectorName(s uint8) string { + switch s { + case 0: + return "0 Cert" + case 1: + return "1 SPKI" + } + return fmt.Sprintf("%d (unknown)", s) +} +func smimeaMatchingTypeName(m uint8) string { + switch m { + case 0: + return "0 Full" + case 1: + return "1 SHA-256" + case 2: + return "2 SHA-512" + } + return fmt.Sprintf("%d (unknown)", m) +} + +func kindRRType(k string) string { + if k == KindSMIMEA { + return "SMIMEA" + } + return "OPENPGPKEY" +} +func kindPrefix(k string) string { + if k == KindSMIMEA { + return "_smimecert" + } + return "_openpgpkey" +} + +func subkeyCaps(sk SubkeyInfo) string { + var caps []string + if sk.CanSign { + caps = append(caps, "sign") + } + if sk.CanEncrypt { + caps = append(caps, "encrypt") + } + if sk.CanAuth { + caps = append(caps, "auth") + } + if len(caps) == 0 { + return "-" + } + return strings.Join(caps, ", ") +} + +func fmtTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} +func fmtTimeOrNever(t time.Time) string { + s := fmtTime(t) + if s == "" { + return "never" + } + return s +} + +func formatFingerprint(fp string) string { + if fp == "" { + return "" + } + fp = strings.ToUpper(fp) + var b strings.Builder + for i, r := range fp { + if i > 0 && i%4 == 0 { + b.WriteByte(' ') + } + b.WriteRune(r) + } + return b.String() +} + +var reportTemplate = template.Must(template.New("openpgpkey").Parse(` + + + + +OPENPGPKEY / SMIMEA report + + + + +
+

+ {{if eq .Kind "openpgpkey"}}OPENPGPKEY record{{else}}SMIMEA record{{end}} + {{.Headline}} +

+
+ Queried: {{.QueriedOwner}} + {{if and .ExpectedOwner (ne .ExpectedOwner .QueriedOwner)}} · expected {{.ExpectedOwner}}{{end}} + {{if .Resolver}} · via {{.Resolver}}{{end}} + {{if eq .DNSSEC "secure"}} · DNSSEC ✓ + {{else if eq .DNSSEC "insecure"}} · DNSSEC ✗ + {{else}} · DNSSEC ?{{end}} + {{if .Username}} · user {{.Username}}{{end}} +
+
+ +{{if .Remediations}} +
+

Most common issues (fix these first)

+ {{range .Remediations}} +
+

{{.Title}}

+
{{.Body}}
+
+ {{end}} +
+{{end}} + +{{with .OpenPGP}} +
+

OpenPGP key

+
+
Fingerprint
{{.Fingerprint}}
+
Key ID
{{.KeyID}}
+
Algorithm
{{.Algorithm}}{{if .Bits}} · {{.Bits}} bits{{end}}
+
Created
{{.Created}}
+
Expires
{{.Expires}}
+
Revoked
{{if .Revoked}}revoked{{else}}no{{end}}
+
Encrypt-capable
{{if .Encrypt}}yes{{else}}no{{end}}
+
Record size
{{.RawSize}} bytes{{if gt .EntityCount 1}} · {{.EntityCount}} entities{{end}}
+
Identities
{{range .UIDs}}
{{.}}
{{else}}(none){{end}}
+
+ {{if .Subkeys}} +

Subkeys

+ + + {{range .Subkeys}} + + + + + + + + + {{end}} +
AlgorithmBitsCapabilitiesCreatedExpiresState
{{.Algorithm}}{{if .Bits}}{{.Bits}}{{end}}{{.Capabilities}}{{.Created}}{{.Expires}}{{if .Revoked}}revoked{{else}}ok{{end}}
+ {{end}} +
+{{end}} + +{{with .SMIMEA}} +
+

SMIMEA record

+
+
Usage
{{.Usage}}
+
Selector
{{.Selector}}
+
Matching type
{{.MatchingType}}
+ {{if .HashOnly}} +
Digest
{{.HashHex}}
+ {{end}} + {{if .Subject}} +
Subject
{{.Subject}}
+
Issuer
{{.Issuer}}
+
Serial
{{.Serial}}
+
Valid from
{{.NotBefore}}
+
Valid until
{{.NotAfter}}
+
Signature
{{.SignatureAlgo}}
+
Public key
{{.KeyAlgo}}{{if .Bits}} · {{.Bits}} bits{{end}}
+
Emails
{{range .Emails}}{{.}} {{else}}(none){{end}}
+
Flags
+ {{if .EmailProtection}}emailProtection{{else}}no emailProtection EKU{{end}} + {{if .DigitalSignature}}digitalSignature{{end}} + {{if .KeyEncipherment}}keyEncipherment{{end}} + {{if .SelfSigned}}self-signed{{end}} + {{if .IsCA}}CA{{end}} +
+ {{else if and .HashOnly .HashHex}} +
Certificate
Digest only; see remediation below.
+ {{end}} +
+
+{{end}} + +
+ {{if .HasStates}} +

Findings {{if .CritCount}}{{.CritCount}} crit{{end}} + {{if .WarnCount}}{{.WarnCount}} warn{{end}} + {{if .InfoCount}}{{.InfoCount}} info{{end}}

+ {{if .Findings}} + + + {{range .Findings}} + + + + + + + {{end}} +
SeverityCodeMessageFix
{{.Severity}}{{.Code}}{{.Message}}{{.Fix}}
+ {{else}} +

No issues detected.

+ {{end}} + {{end}} +

Collected at {{.CollectedAt}}

+
+ + +`)) diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..0aa51b6 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,109 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// issue is a rule-internal description of a failed test. Rules return a +// slice of issues from their check func; Evaluate converts them to +// sdk.CheckState. +type issue struct { + Severity sdk.Status // StatusInfo / StatusWarn / StatusCrit + Message string + Hint string // remediation hint; surfaced as Meta["hint"] + Subject string // optional; overrides default data.QueriedOwner +} + +// ruleFunc consumes the facts + runtime options and returns zero or more +// issues. No issues means the test passed. +type ruleFunc func(d *EmailKeyData, opts sdk.CheckerOptions) []issue + +// rule is a data-driven CheckRule. All per-test rules share this type; +// only name / description / applicable kinds / options / check differ. +type rule struct { + name string + description string + okMessage string // message for StatusOK returns + kinds []string // applicable kinds; empty = both + options sdk.CheckerOptionsDocumentation // per-rule options + check ruleFunc +} + +func (r *rule) Name() string { return r.name } +func (r *rule) Description() string { return r.description } +func (r *rule) Options() sdk.CheckerOptionsDocumentation { return r.options } + +func (r *rule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + var data EmailKeyData + if err := obs.Get(ctx, ObservationKey, &data); err != nil { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to read observation %q: %v", ObservationKey, err), + Code: "openpgpkey_observation_error", + }} + } + + if len(r.kinds) > 0 && !containsString(r.kinds, data.Kind) { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Message: fmt.Sprintf("Not applicable for %s records.", data.Kind), + Code: r.name, + Subject: data.QueriedOwner, + }} + } + + issues := r.check(&data, opts) + if len(issues) == 0 { + msg := r.okMessage + if msg == "" { + msg = "Check passed." + } + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Message: msg, + Code: r.name, + Subject: data.QueriedOwner, + }} + } + + states := make([]sdk.CheckState, 0, len(issues)) + for _, iss := range issues { + subject := iss.Subject + if subject == "" { + subject = data.QueriedOwner + } + var meta map[string]any + if iss.Hint != "" { + meta = map[string]any{"hint": iss.Hint} + } + states = append(states, sdk.CheckState{ + Status: iss.Severity, + Message: iss.Message, + Code: r.name, + Subject: subject, + Meta: meta, + }) + } + return states +} + +// Rules returns the full set of per-test rules for this checker. +func Rules() []sdk.CheckRule { + out := make([]sdk.CheckRule, len(allRules)) + for i := range allRules { + out[i] = allRules[i] + } + return out +} + +func containsString(hay []string, needle string) bool { + for _, v := range hay { + if v == needle { + return true + } + } + return false +} diff --git a/checker/rules_check.go b/checker/rules_check.go new file mode 100644 index 0000000..f0b5beb --- /dev/null +++ b/checker/rules_check.go @@ -0,0 +1,300 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rule names. Each name is also the CheckState.Code emitted by the +// corresponding rule. They are kept as exported constants so callers +// (e.g. the report layer's remediation picker) can reference them +// without copying strings. +const ( + RuleDNSQueryFailed = "dns_query_failed" + RuleDNSNoRecord = "dns_no_record" + RuleDNSRecordMismatch = "dns_record_mismatch" + RuleDNSSECNotValidated = "dnssec_not_validated" + RuleOwnerHashMismatch = "owner_hash_mismatch" + + RulePGPParseError = "pgp_parse_error" + RulePGPPrimaryRevoked = "pgp_primary_revoked" + RulePGPPrimaryExpired = "pgp_primary_expired" + RulePGPPrimaryExpiring = "pgp_primary_expiring_soon" + RulePGPWeakAlgorithm = "pgp_weak_algorithm" + RulePGPWeakKeySize = "pgp_weak_key_size" + RulePGPNoEncryption = "pgp_no_encryption_subkey" + RulePGPNoIdentity = "pgp_no_identity" + RulePGPUIDMismatch = "pgp_uid_mismatch" + RulePGPMultipleEntities = "pgp_multiple_entities" + RulePGPRecordTooLarge = "pgp_record_too_large" + + RuleSMIMEABadUsage = "smimea_bad_usage" + RuleSMIMEABadSelector = "smimea_bad_selector" + RuleSMIMEABadMatchType = "smimea_bad_match_type" + RuleSMIMEACertParseError = "smimea_cert_parse_error" + RuleSMIMEACertNotYetValid = "smimea_cert_not_yet_valid" + RuleSMIMEACertExpired = "smimea_cert_expired" + RuleSMIMEACertExpiring = "smimea_cert_expiring_soon" + RuleSMIMEANoEmailProtect = "smimea_no_email_protection_eku" + RuleSMIMEAMissingKeyUsage = "smimea_missing_key_usage" + RuleSMIMEAWeakSigAlgorithm = "smimea_weak_signature_algorithm" + RuleSMIMEAWeakKeySize = "smimea_weak_key_size" + RuleSMIMEASelfSigned = "smimea_self_signed" + RuleSMIMEAEmailMismatch = "smimea_email_mismatch" + RuleSMIMEAHashOnly = "smimea_hash_only" +) + +var kindsOpenPGP = []string{KindOpenPGPKey} +var kindsSMIMEA = []string{KindSMIMEA} + +// optExpiryWarn is the per-rule option documentation for +// OptionCertExpiryWarnDays. The same option id is shared by the PGP +// expiring-soon rule and the SMIMEA expiring-soon rule. +var optExpiryWarn = sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{{ + Id: OptionCertExpiryWarnDays, + Type: "number", + Label: "Expiry warning threshold (days)", + Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.", + Default: float64(30), + }}, +} + +var optRequireDNSSEC = sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{{ + Id: OptionRequireDNSSEC, + Type: "bool", + Label: "Require DNSSEC", + Description: "When enabled, a non-DNSSEC-validated lookup is reported as critical (otherwise as warning). RFC 7929 and RFC 8162 mandate DNSSEC.", + Default: true, + }}, +} + +var optRequireEmailProtection = sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{{ + Id: OptionRequireEmailProtection, + Type: "bool", + Label: "Require emailProtection EKU", + Description: "When enabled, an S/MIME certificate without the emailProtection Extended Key Usage is reported as critical.", + Default: true, + }}, +} + +// allRules is the canonical list of rules this checker exposes. Each +// entry registers one CheckRule, implemented by the check funcs +// in rules_dns.go, rules_pgp.go, and rules_smimea.go. +var allRules = []*rule{ + // ── DNS / owner (both kinds), rules_dns.go ── + { + name: RuleDNSQueryFailed, + description: "The DNS lookup for the OPENPGPKEY/SMIMEA record must succeed.", + okMessage: "DNS lookup succeeded.", + check: checkDNSQueryFailed, + }, + { + name: RuleDNSNoRecord, + description: "An OPENPGPKEY/SMIMEA record must be published at the expected owner name.", + okMessage: "A record is published at the queried owner name.", + check: checkDNSNoRecord, + }, + { + name: RuleDNSRecordMismatch, + description: "The record returned by DNS must match the service-declared record.", + okMessage: "DNS matches the service-declared record.", + check: checkDNSRecordMismatch, + }, + { + name: RuleDNSSECNotValidated, + description: "The record must be authenticated by DNSSEC; RFC 7929 and RFC 8162 mandate it.", + okMessage: "DNSSEC validated the record (AD flag set).", + options: optRequireDNSSEC, + check: checkDNSSECNotValidated, + }, + { + name: RuleOwnerHashMismatch, + description: "The first label of the owner name must equal hex(sha256(username))[:28].", + okMessage: "Owner-name hash matches the username.", + check: checkOwnerHashMismatch, + }, + + // ── OpenPGP (kind openpgpkey), rules_pgp.go ── + { + name: RulePGPParseError, + description: "The OPENPGPKEY record must decode as a valid OpenPGP key.", + okMessage: "OpenPGP key parsed successfully.", + kinds: kindsOpenPGP, + check: checkPGPParseError, + }, + { + name: RulePGPPrimaryRevoked, + description: "The OpenPGP primary key must not carry a revocation signature.", + okMessage: "Primary key is not revoked.", + kinds: kindsOpenPGP, + check: checkPGPPrimaryRevoked, + }, + { + name: RulePGPPrimaryExpired, + description: "The OpenPGP primary key must not be past its self-signature expiry.", + okMessage: "Primary key is not expired.", + kinds: kindsOpenPGP, + check: checkPGPPrimaryExpired, + }, + { + name: RulePGPPrimaryExpiring, + description: "Warn when the OpenPGP primary key expires within the configured window.", + okMessage: "Primary key is not expiring soon.", + kinds: kindsOpenPGP, + options: optExpiryWarn, + check: checkPGPPrimaryExpiring, + }, + { + name: RulePGPWeakAlgorithm, + description: "The OpenPGP keys must not use legacy algorithms (DSA/ElGamal).", + okMessage: "All OpenPGP keys use modern algorithms.", + kinds: kindsOpenPGP, + check: checkPGPWeakAlgorithm, + }, + { + name: RulePGPWeakKeySize, + description: "OpenPGP RSA keys must be at least 2048 bits (NIST SP 800-131A); 3072+ preferred.", + okMessage: "All RSA OpenPGP keys meet the minimum key size.", + kinds: kindsOpenPGP, + check: checkPGPWeakKeySize, + }, + { + name: RulePGPNoEncryption, + description: "At least one active (non-revoked, non-expired) OpenPGP key must advertise encryption capability.", + okMessage: "The entity has an active encryption-capable key.", + kinds: kindsOpenPGP, + check: checkPGPNoEncryption, + }, + { + name: RulePGPNoIdentity, + description: "The OpenPGP key must carry at least one self-signed User ID.", + okMessage: "The OpenPGP key has at least one identity.", + kinds: kindsOpenPGP, + check: checkPGPNoIdentity, + }, + { + name: RulePGPUIDMismatch, + description: "At least one OpenPGP UID should reference .", + okMessage: "At least one UID matches the username.", + kinds: kindsOpenPGP, + check: checkPGPUIDMismatch, + }, + { + name: RulePGPMultipleEntities, + description: "RFC 7929 recommends a single OpenPGP entity per record.", + okMessage: "The record carries a single OpenPGP entity.", + kinds: kindsOpenPGP, + check: checkPGPMultipleEntities, + }, + { + name: RulePGPRecordTooLarge, + description: "The OPENPGPKEY record should stay below 4 KiB to fit typical UDP answers.", + okMessage: "Record size is within the recommended limit.", + kinds: kindsOpenPGP, + check: checkPGPRecordTooLarge, + }, + + // ── SMIMEA (kind smimea), rules_smimea.go ── + { + name: RuleSMIMEABadUsage, + description: "SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA), or 3 (DANE-EE).", + okMessage: "SMIMEA usage is valid.", + kinds: kindsSMIMEA, + check: checkSMIMEABadUsage, + }, + { + name: RuleSMIMEABadSelector, + description: "SMIMEA selector must be 0 (Cert) or 1 (SPKI).", + okMessage: "SMIMEA selector is valid.", + kinds: kindsSMIMEA, + check: checkSMIMEABadSelector, + }, + { + name: RuleSMIMEABadMatchType, + description: "SMIMEA matching type must be 0 (Full), 1 (SHA-256), or 2 (SHA-512).", + okMessage: "SMIMEA matching type is valid.", + kinds: kindsSMIMEA, + check: checkSMIMEABadMatchType, + }, + { + name: RuleSMIMEACertParseError, + description: "The SMIMEA record must decode as a valid X.509 certificate (or SPKI, for selector 1).", + okMessage: "Certificate parsed successfully.", + kinds: kindsSMIMEA, + check: checkSMIMEACertParseError, + }, + { + name: RuleSMIMEACertNotYetValid, + description: "The S/MIME certificate's NotBefore must be in the past.", + okMessage: "Certificate is within its validity window.", + kinds: kindsSMIMEA, + check: checkSMIMEACertNotYetValid, + }, + { + name: RuleSMIMEACertExpired, + description: "The S/MIME certificate's NotAfter must be in the future.", + okMessage: "Certificate is not expired.", + kinds: kindsSMIMEA, + check: checkSMIMEACertExpired, + }, + { + name: RuleSMIMEACertExpiring, + description: "Warn when the S/MIME certificate expires within the configured window.", + okMessage: "Certificate is not expiring soon.", + kinds: kindsSMIMEA, + options: optExpiryWarn, + check: checkSMIMEACertExpiring, + }, + { + name: RuleSMIMEANoEmailProtect, + description: "The S/MIME certificate must advertise the emailProtection Extended Key Usage (RFC 8550/8551).", + okMessage: "Certificate carries emailProtection EKU.", + kinds: kindsSMIMEA, + options: optRequireEmailProtection, + check: checkSMIMEANoEmailProtect, + }, + { + name: RuleSMIMEAMissingKeyUsage, + description: "The S/MIME certificate must carry digitalSignature and/or keyEncipherment key usage.", + okMessage: "Certificate carries the expected key usages.", + kinds: kindsSMIMEA, + check: checkSMIMEAMissingKeyUsage, + }, + { + name: RuleSMIMEAWeakSigAlgorithm, + description: "The certificate must not be signed with a deprecated algorithm (MD2/MD5/SHA-1 based).", + okMessage: "Certificate uses a strong signature algorithm.", + kinds: kindsSMIMEA, + check: checkSMIMEAWeakSigAlgorithm, + }, + { + name: RuleSMIMEAWeakKeySize, + description: "SMIMEA RSA keys must be at least 2048 bits; 3072+ preferred.", + okMessage: "Certificate key size meets the minimum.", + kinds: kindsSMIMEA, + check: checkSMIMEAWeakKeySize, + }, + { + name: RuleSMIMEASelfSigned, + description: "Self-signed certificates with PKIX-EE (usage 1) are rejected by standard clients.", + okMessage: "Certificate chain is appropriate for the declared usage.", + kinds: kindsSMIMEA, + check: checkSMIMEASelfSigned, + }, + { + name: RuleSMIMEAEmailMismatch, + description: "At least one email SAN on the certificate should begin with @.", + okMessage: "At least one email SAN matches the username.", + kinds: kindsSMIMEA, + check: checkSMIMEAEmailMismatch, + }, + { + name: RuleSMIMEAHashOnly, + description: "SMIMEA matching types 1/2 transport only a digest; the certificate cannot be verified.", + okMessage: "Full certificate is published.", + kinds: kindsSMIMEA, + check: checkSMIMEAHashOnly, + }, +} diff --git a/checker/rules_dns.go b/checker/rules_dns.go new file mode 100644 index 0000000..a01b1a2 --- /dev/null +++ b/checker/rules_dns.go @@ -0,0 +1,78 @@ +package checker + +import ( + "fmt" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// DNS-level rules: lookup outcome, record presence, service/DNS parity, +// DNSSEC authentication, and owner-name hash correctness. These apply +// to both OPENPGPKEY and SMIMEA records. + +func checkDNSQueryFailed(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.DNSQueryError == "" { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: d.DNSQueryError, + Hint: "Check that the zone is published at an authoritative server reachable from this checker.", + }} +} + +func checkDNSNoRecord(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.DNSAnswerPresent == nil || *d.DNSAnswerPresent { + return nil + } + kind := "OPENPGPKEY" + if d.Kind == KindSMIMEA { + kind = "SMIMEA" + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("Authoritative DNS returned no %s record at %s.", kind, d.QueriedOwner), + Hint: "Ensure the record is present in the zone and that the zone has been loaded by the authoritative servers.", + }} +} + +func checkDNSRecordMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.DNSRecordMatchesService == nil || *d.DNSRecordMatchesService { + return nil + } + return []issue{{ + Severity: sdk.StatusWarn, + Message: "The record returned by DNS does not match the one declared in the service. The zone may not have been re-published since the last edit.", + Hint: "Propagate the zone to the authoritative servers, then wait for TTL/negative-cache expiry.", + }} +} + +func checkDNSSECNotValidated(d *EmailKeyData, opts sdk.CheckerOptions) []issue { + if d.DNSSECSecure == nil || *d.DNSSECSecure { + return nil + } + sev := sdk.StatusWarn + if sdk.GetBoolOption(opts, OptionRequireDNSSEC, true) { + sev = sdk.StatusCrit + } + return []issue{{ + Severity: sev, + Message: "The validating resolver did not set the AD flag: the record is not DNSSEC-authenticated, which defeats the whole DANE trust model.", + Hint: "Sign the zone with DNSSEC and publish the DS record at the parent so RFC 7929/8162 consumers can authenticate the key.", + }} +} + +func checkOwnerHashMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.ExpectedOwnerPrefix == "" || d.ObservedOwnerPrefix == "" { + return nil + } + if strings.EqualFold(d.ObservedOwnerPrefix, d.ExpectedOwnerPrefix) { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("Owner name prefix %q does not match SHA-256(%q)[:28]=%q.", d.ObservedOwnerPrefix, d.Username, d.ExpectedOwnerPrefix), + Hint: "Republish the record at the hash-derived name for the intended user, or update the Username field to match the record's owner name.", + }} +} diff --git a/checker/rules_pgp.go b/checker/rules_pgp.go new file mode 100644 index 0000000..ca60a58 --- /dev/null +++ b/checker/rules_pgp.go @@ -0,0 +1,213 @@ +package checker + +import ( + "fmt" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// OpenPGP-specific rules: key parse, revocation, expiry, algorithm and +// key-size strength, encryption capability, identity presence, UID +// matching, RFC 7929 single-entity guidance and record size budget. + +const pgpMaxRecordBytes = 4096 + +func checkPGPParseError(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil { + return []issue{{ + Severity: sdk.StatusCrit, + Message: "Service body has no OPENPGPKEY record.", + Hint: "Attach a valid OPENPGPKEY record to the service.", + }} + } + if d.OpenPGP.ParseError == "" { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: d.OpenPGP.ParseError, + Hint: "Regenerate the key with `gpg --export | base64` and paste the result; do not armor the key.", + }} +} + +func checkPGPPrimaryRevoked(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil || !d.OpenPGP.Revoked { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: "The OpenPGP primary key carries a revocation signature. Consumers will refuse to encrypt to it.", + Hint: "Publish a fresh, non-revoked key at this name, or withdraw the OPENPGPKEY record entirely.", + }} +} + +func checkPGPPrimaryExpired(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil || d.OpenPGP.ExpiresAt.IsZero() { + return nil + } + if !d.OpenPGP.ExpiresAt.Before(time.Now()) { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("The OpenPGP primary key expired on %s.", d.OpenPGP.ExpiresAt.Format(time.RFC3339)), + Hint: "Extend the key's expiry (`gpg --edit-key ` → `expire`) or issue a new key and republish the OPENPGPKEY record.", + }} +} + +func checkPGPPrimaryExpiring(d *EmailKeyData, opts sdk.CheckerOptions) []issue { + if d.OpenPGP == nil || d.OpenPGP.ExpiresAt.IsZero() { + return nil + } + warnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30) + if warnDays <= 0 { + return nil + } + now := time.Now() + window := time.Duration(warnDays) * 24 * time.Hour + exp := d.OpenPGP.ExpiresAt + if exp.Before(now) || exp.Sub(now) >= window { + return nil + } + return []issue{{ + Severity: sdk.StatusWarn, + Message: fmt.Sprintf("The OpenPGP primary key expires on %s.", exp.Format(time.RFC3339)), + Hint: "Extend the key's expiry before it lapses, then re-export and republish.", + }} +} + +func checkPGPWeakAlgorithm(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil { + return nil + } + var out []issue + if isWeakPGPAlgorithm(d.OpenPGP.PrimaryAlgorithm) { + out = append(out, issue{ + Severity: sdk.StatusWarn, + Message: fmt.Sprintf("Primary key uses %s, which modern OpenPGP stacks are phasing out.", d.OpenPGP.PrimaryAlgorithm), + Hint: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.", + Subject: subkeySubject(d.QueriedOwner, "primary"), + }) + } + for i, sk := range d.OpenPGP.Subkeys { + if isWeakPGPAlgorithm(sk.Algorithm) { + out = append(out, issue{ + Severity: sdk.StatusWarn, + Message: fmt.Sprintf("Subkey #%d uses %s, which modern OpenPGP stacks are phasing out.", i+1, sk.Algorithm), + Hint: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.", + Subject: subkeySubject(d.QueriedOwner, fmt.Sprintf("subkey-%d", i+1)), + }) + } + } + return out +} + +func checkPGPWeakKeySize(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil { + return nil + } + var out []issue + if iss := rsaKeySizeIssue(d.OpenPGP.PrimaryAlgorithm, d.OpenPGP.PrimaryBits, "OpenPGP primary"); iss != nil { + iss.Subject = subkeySubject(d.QueriedOwner, "primary") + out = append(out, *iss) + } + for i, sk := range d.OpenPGP.Subkeys { + if iss := rsaKeySizeIssue(sk.Algorithm, sk.Bits, fmt.Sprintf("OpenPGP subkey #%d", i+1)); iss != nil { + iss.Subject = subkeySubject(d.QueriedOwner, fmt.Sprintf("subkey-%d", i+1)) + out = append(out, *iss) + } + } + return out +} + +func checkPGPNoEncryption(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil || d.OpenPGP.ParseError != "" || d.OpenPGP.HasEncryptionCapability { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: "No active (non-revoked, non-expired) key in the entity advertises encryption capability. The record is useless for email encryption.", + Hint: "Generate an encryption subkey (`gpg --edit-key ` → `addkey`) and re-export.", + }} +} + +func checkPGPNoIdentity(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil || d.OpenPGP.ParseError != "" || len(d.OpenPGP.UIDs) > 0 { + return nil + } + return []issue{{ + Severity: sdk.StatusWarn, + Message: "The OpenPGP key has no self-signed User ID. Most clients require at least one identity to bind the key to an email address.", + Hint: "Add a UID containing the user's email (e.g. `gpg --edit-key ` → `adduid`) and re-export.", + }} +} + +func checkPGPUIDMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil || d.OpenPGP.MatchesUsername == nil || *d.OpenPGP.MatchesUsername { + return nil + } + return []issue{{ + Severity: sdk.StatusInfo, + Message: fmt.Sprintf("None of the OpenPGP UIDs reference <%s@…>.", d.Username), + Hint: "Add a UID bound to the email address that the record attests to.", + }} +} + +func checkPGPMultipleEntities(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil || d.OpenPGP.EntityCount <= 1 { + return nil + } + return []issue{{ + Severity: sdk.StatusWarn, + Message: fmt.Sprintf("The record contains %d OpenPGP entities; RFC 7929 recommends a single entity per OPENPGPKEY record.", d.OpenPGP.EntityCount), + Hint: "Split each user's key into its own OPENPGPKEY RR.", + }} +} + +func checkPGPRecordTooLarge(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.OpenPGP == nil || d.OpenPGP.RawSize <= pgpMaxRecordBytes { + return nil + } + return []issue{{ + Severity: sdk.StatusWarn, + Message: fmt.Sprintf("The OpenPGP key packet is %d bytes. Large records force every resolver to fall back to TCP, slowing down the DANE lookup.", d.OpenPGP.RawSize), + Hint: "Publish only the minimum key material needed for email encryption (primary + encryption subkey) and strip image UIDs / extra attributes before export.", + }} +} + +func isWeakPGPAlgorithm(name string) bool { + return name == "DSA" || name == "ElGamal" +} + +// rsaKeySizeIssue returns a non-nil *issue when the given RSA key is +// below NIST's deprecation (2048) or recommendation (3072) thresholds. +// Returns nil for non-RSA algorithms or when bits is 0 (unknown). +func rsaKeySizeIssue(algorithm string, bits int, label string) *issue { + if !strings.EqualFold(algorithm, "RSA") || bits == 0 { + return nil + } + if bits < 2048 { + return &issue{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("%s RSA key of %d bits is considered broken. NIST SP 800-131A deprecates anything below 2048 bits.", label, bits), + Hint: "Generate a fresh RSA-3072/4096 or Ed25519/Curve25519 key and republish.", + } + } + if bits < 3072 { + return &issue{ + Severity: sdk.StatusWarn, + Message: fmt.Sprintf("%s RSA-%d is aging; NIST recommends at least 3072 bits for new deployments.", label, bits), + Hint: "Plan a migration to RSA-3072/4096 or Ed25519/Curve25519 at the next key rotation.", + } + } + return nil +} + +func subkeySubject(owner, label string) string { + if owner == "" { + return label + } + return owner + " [" + label + "]" +} diff --git a/checker/rules_smimea.go b/checker/rules_smimea.go new file mode 100644 index 0000000..98e359d --- /dev/null +++ b/checker/rules_smimea.go @@ -0,0 +1,231 @@ +package checker + +import ( + "fmt" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// SMIMEA-specific rules: field-value validity (usage/selector/matching +// type), certificate parse, validity window, extended key usage, key +// usage flags, signature-algorithm and key-size strength, self-signed +// handling, email SAN/username pairing, and digest-only guidance. + +func checkSMIMEABadUsage(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.SMIMEA == nil || d.SMIMEA.Usage <= 3 { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("Unknown SMIMEA usage %d (expected 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE).", d.SMIMEA.Usage), + Hint: "Use usage 3 (DANE-EE) for self-hosted S/MIME certificates.", + }} +} + +func checkSMIMEABadSelector(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.SMIMEA == nil || d.SMIMEA.Selector <= 1 { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("Unknown SMIMEA selector %d (expected 0 Cert or 1 SPKI).", d.SMIMEA.Selector), + Hint: "Use selector 0 to publish the full certificate.", + }} +} + +func checkSMIMEABadMatchType(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.SMIMEA == nil || d.SMIMEA.MatchingType <= 2 { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("Unknown SMIMEA matching type %d (expected 0 Full, 1 SHA-256, 2 SHA-512).", d.SMIMEA.MatchingType), + Hint: "Use matching type 0 so the whole certificate is transported, or type 1 (SHA-256) for a digest.", + }} +} + +func checkSMIMEACertParseError(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.SMIMEA == nil { + return []issue{{ + Severity: sdk.StatusCrit, + Message: "Service body has no SMIMEA record.", + Hint: "Attach a valid SMIMEA record to the service.", + }} + } + if d.SMIMEA.ParseError == "" { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: d.SMIMEA.ParseError, + Hint: "Ensure the certificate is DER-encoded (not PEM) before hex-encoding it into SMIMEA RDATA.", + }} +} + +func checkSMIMEACertNotYetValid(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + ci := smimeaCert(d) + if ci == nil || ci.NotBefore.IsZero() { + return nil + } + if !time.Now().Before(ci.NotBefore) { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("Certificate is not yet valid (NotBefore = %s).", ci.NotBefore.Format(time.RFC3339)), + Hint: "Check the system clock on the CA/signer, or wait until the certificate's notBefore date.", + }} +} + +func checkSMIMEACertExpired(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + ci := smimeaCert(d) + if ci == nil || ci.NotAfter.IsZero() { + return nil + } + if !time.Now().After(ci.NotAfter) { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("Certificate expired on %s.", ci.NotAfter.Format(time.RFC3339)), + Hint: "Issue a fresh certificate and republish the SMIMEA record.", + }} +} + +func checkSMIMEACertExpiring(d *EmailKeyData, opts sdk.CheckerOptions) []issue { + ci := smimeaCert(d) + if ci == nil || ci.NotAfter.IsZero() { + return nil + } + warnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30) + if warnDays <= 0 { + return nil + } + now := time.Now() + window := time.Duration(warnDays) * 24 * time.Hour + if ci.NotAfter.Before(now) || ci.NotAfter.Sub(now) >= window { + return nil + } + return []issue{{ + Severity: sdk.StatusWarn, + Message: fmt.Sprintf("Certificate expires on %s.", ci.NotAfter.Format(time.RFC3339)), + Hint: "Renew before expiry and update the SMIMEA record with the new certificate.", + }} +} + +func checkSMIMEANoEmailProtect(d *EmailKeyData, opts sdk.CheckerOptions) []issue { + ci := smimeaCert(d) + if ci == nil || ci.HasEmailProtectionEKU { + return nil + } + sev := sdk.StatusWarn + if sdk.GetBoolOption(opts, OptionRequireEmailProtection, true) { + sev = sdk.StatusCrit + } + return []issue{{ + Severity: sev, + Message: "Certificate lacks the emailProtection Extended Key Usage; RFC 8550/8551 agents will refuse it.", + Hint: "Re-issue the certificate with `extendedKeyUsage = emailProtection` (OID 1.3.6.1.5.5.7.3.4).", + }} +} + +func checkSMIMEAMissingKeyUsage(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + ci := smimeaCert(d) + if ci == nil || ci.HasDigitalSignature || ci.HasKeyEncipherment { + return nil + } + return []issue{{ + Severity: sdk.StatusWarn, + Message: "Certificate has neither digitalSignature nor keyEncipherment key usage; S/MIME signing or encryption will be refused.", + Hint: "Add `keyUsage = digitalSignature, keyEncipherment` to the certificate profile.", + }} +} + +var weakSMIMEASignatureAlgorithms = map[string]bool{ + "MD2-RSA": true, + "MD5-RSA": true, + "SHA1-RSA": true, + "DSA-SHA1": true, + "ECDSA-SHA1": true, +} + +func checkSMIMEAWeakSigAlgorithm(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + ci := smimeaCert(d) + if ci == nil || ci.SignatureAlgorithm == "" { + return nil + } + if !weakSMIMEASignatureAlgorithms[ci.SignatureAlgorithm] { + return nil + } + return []issue{{ + Severity: sdk.StatusCrit, + Message: fmt.Sprintf("Certificate is signed with %s, a deprecated algorithm.", ci.SignatureAlgorithm), + Hint: "Re-issue the certificate with SHA-256 (or better) signatures.", + }} +} + +func checkSMIMEAWeakKeySize(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.SMIMEA == nil { + return nil + } + algo, bits := "", 0 + switch { + case d.SMIMEA.Certificate != nil: + algo, bits = d.SMIMEA.Certificate.PublicKeyAlgorithm, d.SMIMEA.Certificate.PublicKeyBits + case d.SMIMEA.PublicKey != nil: + algo, bits = d.SMIMEA.PublicKey.Algorithm, d.SMIMEA.PublicKey.Bits + default: + return nil + } + if iss := rsaKeySizeIssue(algo, bits, "Certificate"); iss != nil { + return []issue{*iss} + } + return nil +} + +func checkSMIMEASelfSigned(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + ci := smimeaCert(d) + if ci == nil || !ci.IsSelfSigned { + return nil + } + if d.SMIMEA.Usage != 1 && d.SMIMEA.Usage != 3 { + return nil + } + return []issue{{ + Severity: sdk.StatusInfo, + Message: "End-entity usage advertises a self-signed certificate; DANE-EE (usage 3) makes this safe, but PKIX-EE (usage 1) consumers will reject it.", + Hint: "Switch the record to usage 3 (DANE-EE) if you operate your own CA, or chain the certificate under a public CA for usage 1.", + }} +} + +func checkSMIMEAEmailMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + ci := smimeaCert(d) + if ci == nil || ci.EmailMatchesUsername == nil || *ci.EmailMatchesUsername { + return nil + } + return []issue{{ + Severity: sdk.StatusInfo, + Message: fmt.Sprintf("None of the certificate's email SANs (%s) begin with %s@; clients that strictly match SAN to envelope address will reject it.", strings.Join(ci.EmailAddresses, ", "), d.Username), + Hint: "Re-issue the certificate with the correct `subjectAltName = email:@`.", + }} +} + +func checkSMIMEAHashOnly(d *EmailKeyData, _ sdk.CheckerOptions) []issue { + if d.SMIMEA == nil || d.SMIMEA.MatchingType == 0 { + return nil + } + return []issue{{ + Severity: sdk.StatusInfo, + Message: "Record carries only a digest; the certificate itself cannot be verified by this checker.", + Hint: "Switch to matching type 0 (Full) to let verifiers inspect and pin the certificate.", + }} +} + +func smimeaCert(d *EmailKeyData) *CertInfo { + if d.SMIMEA == nil { + return nil + } + return d.SMIMEA.Certificate +} diff --git a/checker/rules_test.go b/checker/rules_test.go new file mode 100644 index 0000000..7aee591 --- /dev/null +++ b/checker/rules_test.go @@ -0,0 +1,330 @@ +package checker + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func boolPtr(b bool) *bool { return &b } + +// fakeObs implements sdk.ObservationGetter against an in-memory map. +type fakeObs struct { + store map[string]any +} + +func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error { + v, ok := f.store[key] + if !ok { + return errFake("missing observation: " + key) + } + raw, err := json.Marshal(v) + if err != nil { + return err + } + return json.Unmarshal(raw, dest) +} + +func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return nil, nil +} + +type errFake string + +func (e errFake) Error() string { return string(e) } + +// ── DNS rules ──────────────────────────────────────────────────────────────── + +func TestCheckDNSQueryFailed(t *testing.T) { + if got := checkDNSQueryFailed(&EmailKeyData{}, nil); got != nil { + t.Errorf("expected no issue, got %+v", got) + } + got := checkDNSQueryFailed(&EmailKeyData{DNSQueryError: "timeout"}, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusCrit { + t.Errorf("expected one crit issue, got %+v", got) + } +} + +func TestCheckDNSNoRecord(t *testing.T) { + // nil DNSAnswerPresent ⇒ no judgement. + if got := checkDNSNoRecord(&EmailKeyData{}, nil); got != nil { + t.Errorf("expected no issue when present is nil, got %+v", got) + } + // Present=true ⇒ no issue. + if got := checkDNSNoRecord(&EmailKeyData{DNSAnswerPresent: boolPtr(true)}, nil); got != nil { + t.Errorf("expected no issue when present, got %+v", got) + } + // Present=false ⇒ crit. + got := checkDNSNoRecord(&EmailKeyData{Kind: KindSMIMEA, QueriedOwner: "x", DNSAnswerPresent: boolPtr(false)}, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusCrit || !strings.Contains(got[0].Message, "SMIMEA") { + t.Errorf("unexpected: %+v", got) + } +} + +func TestCheckDNSSECNotValidated_Severity(t *testing.T) { + d := &EmailKeyData{DNSSECSecure: boolPtr(false)} + // Default: requireDNSSEC=true ⇒ crit. + got := checkDNSSECNotValidated(d, sdk.CheckerOptions{}) + if len(got) != 1 || got[0].Severity != sdk.StatusCrit { + t.Errorf("default should be crit, got %+v", got) + } + // Override to false ⇒ warn. + got = checkDNSSECNotValidated(d, sdk.CheckerOptions{OptionRequireDNSSEC: false}) + if len(got) != 1 || got[0].Severity != sdk.StatusWarn { + t.Errorf("opt-off should be warn, got %+v", got) + } + // Secure ⇒ no issue. + got = checkDNSSECNotValidated(&EmailKeyData{DNSSECSecure: boolPtr(true)}, nil) + if got != nil { + t.Errorf("expected no issue, got %+v", got) + } +} + +func TestCheckOwnerHashMismatch(t *testing.T) { + d := &EmailKeyData{Username: "alice", ExpectedOwnerPrefix: "abc", ObservedOwnerPrefix: "abc"} + if got := checkOwnerHashMismatch(d, nil); got != nil { + t.Errorf("matching prefixes should not issue, got %+v", got) + } + d.ObservedOwnerPrefix = "ABC" // case-insensitive + if got := checkOwnerHashMismatch(d, nil); got != nil { + t.Errorf("case-insensitive match should not issue, got %+v", got) + } + d.ObservedOwnerPrefix = "xyz" + got := checkOwnerHashMismatch(d, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusCrit { + t.Errorf("mismatch should crit, got %+v", got) + } + // Either prefix empty ⇒ skip silently. + d.ObservedOwnerPrefix = "" + if got := checkOwnerHashMismatch(d, nil); got != nil { + t.Errorf("empty observed should skip, got %+v", got) + } +} + +// ── PGP rules ──────────────────────────────────────────────────────────────── + +func TestCheckPGPParseError(t *testing.T) { + got := checkPGPParseError(&EmailKeyData{}, nil) + if len(got) != 1 || !strings.Contains(got[0].Message, "no OPENPGPKEY") { + t.Errorf("expected no-record issue, got %+v", got) + } + got = checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{ParseError: "boom"}}, nil) + if len(got) != 1 || got[0].Message != "boom" { + t.Errorf("expected parse-error issue, got %+v", got) + } + if got := checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{}}, nil); got != nil { + t.Errorf("expected no issue, got %+v", got) + } +} + +func TestCheckPGPPrimaryExpired(t *testing.T) { + past := time.Now().Add(-1 * time.Hour) + d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: past}} + got := checkPGPPrimaryExpired(d, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusCrit { + t.Errorf("expected crit, got %+v", got) + } + d.OpenPGP.ExpiresAt = time.Now().Add(time.Hour) + if got := checkPGPPrimaryExpired(d, nil); got != nil { + t.Errorf("future expiry should not issue, got %+v", got) + } +} + +func TestCheckPGPPrimaryExpiring(t *testing.T) { + soon := time.Now().Add(10 * 24 * time.Hour) + d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: soon}} + // Default 30-day window ⇒ warn. + got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{}) + if len(got) != 1 || got[0].Severity != sdk.StatusWarn { + t.Errorf("expected warn, got %+v", got) + } + // Already expired ⇒ this rule does not fire (the expired rule does). + d.OpenPGP.ExpiresAt = time.Now().Add(-time.Hour) + if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{}); got != nil { + t.Errorf("expired key should not trigger expiring rule, got %+v", got) + } + // Disable via warnDays=0 ⇒ no issue. + d.OpenPGP.ExpiresAt = soon + if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{OptionCertExpiryWarnDays: float64(0)}); got != nil { + t.Errorf("warnDays=0 should disable, got %+v", got) + } +} + +func TestCheckPGPWeakKeySize(t *testing.T) { + d := &EmailKeyData{OpenPGP: &OpenPGPInfo{PrimaryAlgorithm: "RSA", PrimaryBits: 1024}} + got := checkPGPWeakKeySize(d, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusCrit { + t.Errorf("1024-bit RSA should be crit, got %+v", got) + } + d.OpenPGP.PrimaryBits = 2048 + got = checkPGPWeakKeySize(d, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusWarn { + t.Errorf("2048-bit RSA should be warn, got %+v", got) + } + d.OpenPGP.PrimaryBits = 4096 + if got := checkPGPWeakKeySize(d, nil); got != nil { + t.Errorf("4096-bit RSA should pass, got %+v", got) + } + // Non-RSA ⇒ skip. + d.OpenPGP.PrimaryAlgorithm = "Ed25519" + d.OpenPGP.PrimaryBits = 256 + if got := checkPGPWeakKeySize(d, nil); got != nil { + t.Errorf("Ed25519 should skip, got %+v", got) + } +} + +func TestCheckPGPRecordTooLarge(t *testing.T) { + d := &EmailKeyData{OpenPGP: &OpenPGPInfo{RawSize: pgpMaxRecordBytes + 1}} + got := checkPGPRecordTooLarge(d, nil) + if len(got) != 1 { + t.Errorf("expected one issue, got %+v", got) + } + d.OpenPGP.RawSize = pgpMaxRecordBytes + if got := checkPGPRecordTooLarge(d, nil); got != nil { + t.Errorf("at-limit should pass, got %+v", got) + } +} + +func TestCheckPGPUIDMismatch(t *testing.T) { + d := &EmailKeyData{Username: "alice", OpenPGP: &OpenPGPInfo{MatchesUsername: boolPtr(false)}} + got := checkPGPUIDMismatch(d, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusInfo { + t.Errorf("expected info issue, got %+v", got) + } + d.OpenPGP.MatchesUsername = boolPtr(true) + if got := checkPGPUIDMismatch(d, nil); got != nil { + t.Errorf("matching should pass, got %+v", got) + } + d.OpenPGP.MatchesUsername = nil + if got := checkPGPUIDMismatch(d, nil); got != nil { + t.Errorf("nil should skip, got %+v", got) + } +} + +// ── SMIMEA rules ───────────────────────────────────────────────────────────── + +func TestCheckSMIMEAFieldRanges(t *testing.T) { + if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 4}}, nil); len(got) != 1 { + t.Errorf("usage=4 should issue, got %+v", got) + } + if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 3}}, nil); got != nil { + t.Errorf("usage=3 should pass, got %+v", got) + } + if got := checkSMIMEABadSelector(&EmailKeyData{SMIMEA: &SMIMEAInfo{Selector: 2}}, nil); len(got) != 1 { + t.Errorf("selector=2 should issue") + } + if got := checkSMIMEABadMatchType(&EmailKeyData{SMIMEA: &SMIMEAInfo{MatchingType: 3}}, nil); len(got) != 1 { + t.Errorf("matching=3 should issue") + } +} + +func TestCheckSMIMEACertExpired(t *testing.T) { + past := time.Now().Add(-time.Hour) + d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{NotAfter: past}}} + got := checkSMIMEACertExpired(d, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusCrit { + t.Errorf("expected crit, got %+v", got) + } +} + +func TestCheckSMIMEANoEmailProtect_Severity(t *testing.T) { + d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{}}} + // Default true ⇒ crit. + if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); len(got) != 1 || got[0].Severity != sdk.StatusCrit { + t.Errorf("default crit, got %+v", got) + } + // Off ⇒ warn. + if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{OptionRequireEmailProtection: false}); got[0].Severity != sdk.StatusWarn { + t.Errorf("opt-off should warn, got %+v", got) + } + // Has EKU ⇒ no issue. + d.SMIMEA.Certificate.HasEmailProtectionEKU = true + if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); got != nil { + t.Errorf("EKU present should pass, got %+v", got) + } +} + +func TestCheckSMIMEAWeakSigAlgorithm(t *testing.T) { + for _, algo := range []string{"MD5-RSA", "SHA1-RSA"} { + d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: algo}}} + if got := checkSMIMEAWeakSigAlgorithm(d, nil); len(got) != 1 { + t.Errorf("%s should issue", algo) + } + } + d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: "SHA256-RSA"}}} + if got := checkSMIMEAWeakSigAlgorithm(d, nil); got != nil { + t.Errorf("SHA256-RSA should pass, got %+v", got) + } +} + +func TestCheckSMIMEAEmailMismatch(t *testing.T) { + d := &EmailKeyData{Username: "alice", SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{ + EmailAddresses: []string{"bob@example.com"}, + EmailMatchesUsername: boolPtr(false), + }}} + got := checkSMIMEAEmailMismatch(d, nil) + if len(got) != 1 || got[0].Severity != sdk.StatusInfo { + t.Errorf("expected info, got %+v", got) + } +} + +// ── Rule.Evaluate plumbing ─────────────────────────────────────────────────── + +func TestRuleEvaluate_OKPath(t *testing.T) { + obs := &fakeObs{store: map[string]any{ + ObservationKey: &EmailKeyData{Kind: KindOpenPGPKey, QueriedOwner: "x.example.com.", DNSSECSecure: boolPtr(true)}, + }} + for _, r := range allRules { + if r.name != RuleDNSSECNotValidated { + continue + } + states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{}) + if len(states) != 1 || states[0].Status != sdk.StatusOK || states[0].Code != RuleDNSSECNotValidated { + t.Fatalf("expected single OK state, got %+v", states) + } + } +} + +func TestRuleEvaluate_KindFiltering(t *testing.T) { + obs := &fakeObs{store: map[string]any{ + ObservationKey: &EmailKeyData{Kind: KindSMIMEA}, + }} + for _, r := range allRules { + if r.name != RulePGPParseError { + continue + } + states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{}) + if len(states) != 1 || states[0].Status != sdk.StatusUnknown { + t.Fatalf("PGP rule on SMIMEA kind should yield single Unknown state, got %+v", states) + } + } +} + +func TestRuleEvaluate_MissingObservation(t *testing.T) { + obs := &fakeObs{store: map[string]any{}} + r := allRules[0] + states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{}) + if len(states) != 1 || states[0].Status != sdk.StatusError { + t.Fatalf("expected single Error state, got %+v", states) + } +} + +func TestRulesUniqueNames(t *testing.T) { + seen := map[string]bool{} + for _, r := range allRules { + if seen[r.name] { + t.Errorf("duplicate rule name: %s", r.name) + } + seen[r.name] = true + if r.check == nil { + t.Errorf("rule %s has nil check func", r.name) + } + if r.okMessage == "" { + t.Errorf("rule %s has empty okMessage", r.name) + } + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..5085872 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,219 @@ +// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for +// happyDomain. It gathers the facts published by a zone for an +// abstract.OpenPGP or abstract.SMimeCert service (DNS lookup, DNSSEC +// flag, parsed OpenPGP key, parsed X.509 certificate) and lets a +// family of per-test rules judge those facts. +package checker + +import ( + "encoding/json" + "time" +) + +// ObservationKey is the key this checker publishes. The payload is an +// *EmailKeyData JSON document. +const ObservationKey = "openpgpkey_smimea" + +// Supported service types. +const ( + ServiceOpenPGP = "abstract.OpenPGP" + ServiceSMimeCert = "abstract.SMimeCert" + KindOpenPGPKey = "openpgpkey" + KindSMIMEA = "smimea" + OpenPGPKeyPrefix = "_openpgpkey" + SMIMEACertPrefix = "_smimecert" + DANEOwnerHashSize = 28 // bytes of SHA-256 kept as the owner prefix +) + +// EmailKeyData is the observation payload written under ObservationKey. +// It carries only facts; no severities, no judgment, rules decide +// what's OK and what isn't. +type EmailKeyData struct { + // Kind is "openpgpkey" or "smimea". + Kind string `json:"kind"` + + // Domain is the FQDN of the zone (origin) that publishes the record. + Domain string `json:"domain"` + + // Subdomain is the relative name below Domain where the service sits + // (empty for the zone apex). + Subdomain string `json:"subdomain,omitempty"` + + // Username is the local part copied from the service. When empty, + // the username-hash-prefix verification is skipped. + Username string `json:"username,omitempty"` + + // ExpectedOwner is the FQDN at which the record should be published, + // per RFC 7929 / RFC 8162. + ExpectedOwner string `json:"expected_owner,omitempty"` + + // QueriedOwner is the FQDN actually queried (may differ from + // ExpectedOwner if the service record already carries its own name). + QueriedOwner string `json:"queried_owner,omitempty"` + + // Resolver is the DNS server that answered the lookup. + Resolver string `json:"resolver,omitempty"` + + // DNSQueryError is non-empty when the DNS lookup itself failed (no + // answer received, transport error, etc.). + DNSQueryError string `json:"dns_query_error,omitempty"` + + // DNSAnswerPresent is nil when the lookup did not complete, false + // when the authoritative answer was NXDOMAIN / empty, true otherwise. + DNSAnswerPresent *bool `json:"dns_answer_present,omitempty"` + + // DNSSECSecure is true when the validating resolver set the AD flag + // on the answer. Nil means the lookup did not complete. + DNSSECSecure *bool `json:"dnssec_secure,omitempty"` + + // DNSRecordMatchesService is the result of comparing the DNS-returned + // record bytes against the service-body bytes. Nil when the + // comparison could not run (DNS failed, or the service body has no + // record to compare against). + DNSRecordMatchesService *bool `json:"dns_record_matches_service,omitempty"` + + // ObservedOwnerPrefix is the hash-shaped first label extracted from + // QueriedOwner (._openpgpkey.<…> / ._smimecert.<…>), or + // empty when the owner does not follow that shape. + ObservedOwnerPrefix string `json:"observed_owner_prefix,omitempty"` + + // ExpectedOwnerPrefix is hex(sha256(Username))[:28]. Empty when + // Username is empty. + ExpectedOwnerPrefix string `json:"expected_owner_prefix,omitempty"` + + // RecordCount is the number of records returned at QueriedOwner. + RecordCount int `json:"record_count"` + + // OpenPGP is populated for kind=openpgpkey. + OpenPGP *OpenPGPInfo `json:"openpgp,omitempty"` + + // SMIMEA is populated for kind=smimea. + SMIMEA *SMIMEAInfo `json:"smimea,omitempty"` + + CollectedAt time.Time `json:"collected_at"` +} + +// OpenPGPInfo summarises the OpenPGP key observed in the record. +type OpenPGPInfo struct { + // ParseError is non-empty when the record could not be decoded as a + // valid OpenPGP key (bad base64, unreadable packet stream, no + // entity, or no record attached to the service at all). Remaining + // fields may be zero-valued on this path. + ParseError string `json:"parse_error,omitempty"` + + // RawSize is the length in bytes of the transport key material. + RawSize int `json:"raw_size"` + + // PrimaryAlgorithm is the name of the primary key's algorithm, + // e.g. "RSA", "Ed25519", "ECDSA-NIST-P-256". + PrimaryAlgorithm string `json:"primary_algorithm,omitempty"` + + // PrimaryBits is the key size in bits for the primary key (0 when + // the algorithm is of fixed size, e.g. Ed25519). + PrimaryBits int `json:"primary_bits,omitempty"` + + // Fingerprint is the hex-encoded OpenPGP fingerprint. + Fingerprint string `json:"fingerprint,omitempty"` + + // KeyID is the short 64-bit key id, hex. + KeyID string `json:"key_id,omitempty"` + + // UIDs lists the User ID strings carried in the key. + UIDs []string `json:"uids,omitempty"` + + // CreatedAt is the primary key creation time. + CreatedAt time.Time `json:"created_at,omitempty"` + + // ExpiresAt is the primary key expiration time (zero for "never"). + ExpiresAt time.Time `json:"expires_at,omitempty"` + + // Revoked is true when the primary key carries a revocation signature. + Revoked bool `json:"revoked,omitempty"` + + // MatchesUsername is nil when the check was not run (no UIDs or no + // username), true when at least one UID references , + // false otherwise. + MatchesUsername *bool `json:"matches_username,omitempty"` + + // Subkeys describes the subordinate keys. + Subkeys []SubkeyInfo `json:"subkeys,omitempty"` + + // EntityCount is the number of OpenPGP entities parsed from the + // record. RFC 7929 recommends a single entity per record. + EntityCount int `json:"entity_count"` + + // HasEncryptionCapability is true when at least one non-revoked, + // non-expired key in the entity advertises encryption usage flags. + HasEncryptionCapability bool `json:"has_encryption_capability"` +} + +// SubkeyInfo summarises one OpenPGP subkey. +type SubkeyInfo struct { + Algorithm string `json:"algorithm"` + Bits int `json:"bits,omitempty"` + CanSign bool `json:"can_sign,omitempty"` + CanEncrypt bool `json:"can_encrypt,omitempty"` + CanAuth bool `json:"can_auth,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Revoked bool `json:"revoked,omitempty"` +} + +// SMIMEAInfo summarises the S/MIME record. +type SMIMEAInfo struct { + // ParseError is non-empty when the certificate / SPKI bytes cannot + // be parsed. + ParseError string `json:"parse_error,omitempty"` + + Usage uint8 `json:"usage"` + Selector uint8 `json:"selector"` + MatchingType uint8 `json:"matching_type"` + + // Certificate is populated when the record carries a full X.509 + // certificate (selector 0, matching type 0). For selector 1 + type 0 + // only PublicKey is populated. For matching types 1/2, neither is + // populated; only the digest is transported. + Certificate *CertInfo `json:"certificate,omitempty"` + PublicKey *PubKeyInfo `json:"public_key,omitempty"` + + // HashHex, when set, is the hex digest embedded in the record. + HashHex string `json:"hash_hex,omitempty"` +} + +// CertInfo summarises an X.509 certificate. +type CertInfo struct { + Subject string `json:"subject,omitempty"` + Issuer string `json:"issuer,omitempty"` + SerialHex string `json:"serial_hex,omitempty"` + NotBefore time.Time `json:"not_before,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + SignatureAlgorithm string `json:"signature_algorithm,omitempty"` + PublicKeyAlgorithm string `json:"public_key_algorithm,omitempty"` + PublicKeyBits int `json:"public_key_bits,omitempty"` + EmailAddresses []string `json:"email_addresses,omitempty"` + DNSNames []string `json:"dns_names,omitempty"` + HasEmailProtectionEKU bool `json:"has_email_protection_eku,omitempty"` + HasDigitalSignature bool `json:"has_digital_signature,omitempty"` + HasKeyEncipherment bool `json:"has_key_encipherment,omitempty"` + IsSelfSigned bool `json:"is_self_signed,omitempty"` + IsCA bool `json:"is_ca,omitempty"` + + // EmailMatchesUsername is nil when the check was not run (no + // username or no email SAN on the certificate), true when at least + // one SAN begins with "@", false otherwise. + EmailMatchesUsername *bool `json:"email_matches_username,omitempty"` +} + +// PubKeyInfo summarises an SPKI-only SMIMEA record. +type PubKeyInfo struct { + Algorithm string `json:"algorithm,omitempty"` + Bits int `json:"bits,omitempty"` +} + +// serviceMessage is a minimal mirror of happyDomain's ServiceMessage JSON +// envelope used to carry the auto-filled service. +type serviceMessage struct { + Type string `json:"_svctype"` + Domain string `json:"_domain"` + Service json.RawMessage `json:"Service"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d3155f3 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module git.happydns.org/checker-email-keys + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v1.4.0 + github.com/ProtonMail/go-crypto v1.1.0-alpha.0 + github.com/miekg/dns v1.1.72 +) + +require ( + github.com/cloudflare/circl v1.3.7 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a091007 --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs= +git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/ProtonMail/go-crypto v1.1.0-alpha.0 h1:nHGfwXmFvJrSR9xu8qL7BkO4DqTHXE9N5vPhgY2I+j0= +github.com/ProtonMail/go-crypto v1.1.0-alpha.0/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= diff --git a/main.go b/main.go new file mode 100644 index 0000000..12d8ed2 --- /dev/null +++ b/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "flag" + "log" + + emailkeys "git.happydns.org/checker-email-keys/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +// Version is the standalone binary's version. Override with: +// +// go build -ldflags "-X main.Version=1.2.3" . +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + emailkeys.Version = Version + + srv := server.New(emailkeys.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..aab5595 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,22 @@ +// Command plugin is the happyDomain plugin entrypoint for the +// OPENPGPKEY/SMIMEA checker. Built as a Go plugin and loaded at runtime +// by happyDomain. +package main + +import ( + emailkeys "git.happydns.org/checker-email-keys/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is the plugin's version, meant to be overridden by CI: +// +// go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-email-keys.so ./plugin +var Version = "custom-build" + +// NewCheckerPlugin is the symbol resolved by happyDomain when loading +// the .so file. +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + emailkeys.Version = Version + prvd := emailkeys.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}