Initial commit
This commit is contained in:
commit
19296f4188
18 changed files with 2562 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-email-keys
|
||||
checker-email-keys.so
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-email-keys .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-email-keys /checker-email-keys
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/checker-email-keys"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
25
Makefile
Normal file
25
Makefile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
CHECKER_NAME := checker-email-keys
|
||||
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||
CHECKER_VERSION ?= custom-build
|
||||
|
||||
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||
|
||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||
|
||||
.PHONY: all plugin docker clean
|
||||
|
||||
all: $(CHECKER_NAME)
|
||||
|
||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||
|
||||
plugin: $(CHECKER_NAME).so
|
||||
|
||||
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||
|
||||
docker:
|
||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||
|
||||
clean:
|
||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||
31
NOTICE
Normal file
31
NOTICE
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
checker-email-keys
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
OpenPGP parsing
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product uses github.com/ProtonMail/go-crypto/openpgp (fork of the
|
||||
deprecated golang.org/x/crypto/openpgp), BSD-style license.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
DNS
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product uses github.com/miekg/dns, BSD-style license.
|
||||
89
README.md
Normal file
89
README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# checker-email-keys
|
||||
|
||||
DANE-Email posture checker for happyDomain.
|
||||
|
||||
Runs a comprehensive testsuite on a domain's DNS-published OpenPGP key
|
||||
(`OPENPGPKEY`, [RFC 7929][rfc7929]) or S/MIME certificate (`SMIMEA`,
|
||||
[RFC 8162][rfc8162]) and renders an actionable HTML report whose top
|
||||
block nudges the user toward the fix for the most common failure
|
||||
scenarios.
|
||||
|
||||
This checker binds to the happyDomain services:
|
||||
|
||||
- `abstract.OpenPGP`: individual user's PGP key, owner-hashed below
|
||||
`._openpgpkey.<zone>`.
|
||||
- `abstract.SMimeCert`: user's S/MIME certificate, owner-hashed below
|
||||
`._smimecert.<zone>`.
|
||||
|
||||
[rfc7929]: https://www.rfc-editor.org/rfc/rfc7929
|
||||
[rfc8162]: https://www.rfc-editor.org/rfc/rfc8162
|
||||
|
||||
## Tests run
|
||||
|
||||
All findings are tagged by severity (`info` / `warn` / `crit`) so the
|
||||
rule engine can fold them into a single `CheckState`.
|
||||
|
||||
### DNS (both record types)
|
||||
|
||||
| Code | Severity | What it catches |
|
||||
| --- | --- | --- |
|
||||
| `dns_query_failed` | crit | The resolver returned an error or did not answer. |
|
||||
| `dns_no_record` | crit | The authoritative answer has no record at the expected owner. |
|
||||
| `dnssec_not_validated` | crit / warn | The validating resolver did not set `AD`. RFC 7929/8162 mandate DNSSEC; the severity is configurable via `requireDNSSEC`. |
|
||||
| `dns_record_mismatch` | warn | The record returned by DNS differs from the one declared in the service (typically a stale zone on the authoritative servers). |
|
||||
| `owner_hash_mismatch` | crit | Record owner-name first label is not `sha256(localpart)[:28]`; mail clients will never find it. |
|
||||
|
||||
### OpenPGP-specific (RFC 7929)
|
||||
|
||||
| Code | Severity | What it catches |
|
||||
| --- | --- | --- |
|
||||
| `pgp_parse_error` | crit | Malformed base64 or OpenPGP packet stream. |
|
||||
| `pgp_no_entity` | crit | Record decoded but carries no valid entity. |
|
||||
| `pgp_primary_revoked` | crit | Primary key has a revocation signature. |
|
||||
| `pgp_primary_expired` | crit | Self-signature expired; clients will refuse to encrypt. |
|
||||
| `pgp_primary_expiring_soon` | warn | Expires within the `certExpiryWarnDays` window (default 30). |
|
||||
| `pgp_weak_algorithm` | warn | Uses DSA / ElGamal (phase-out). |
|
||||
| `pgp_weak_key_size` | crit / warn | RSA below 2048 bits is critical, 2048-3071 is a warn. |
|
||||
| `pgp_no_encryption_subkey` | crit | No active key in the entity advertises encryption capability. |
|
||||
| `pgp_no_identity` | warn | No self-signed User ID. |
|
||||
| `pgp_uid_mismatch` | info | None of the UIDs reference `<username@…>`. |
|
||||
| `pgp_multiple_entities` | warn | Record carries more than one entity (RFC 7929 recommends one). |
|
||||
| `pgp_record_too_large` | warn | Raw key > 4 KiB; forces UDP→TCP fallback on every lookup. |
|
||||
|
||||
### SMIMEA-specific (RFC 8162)
|
||||
|
||||
| Code | Severity | What it catches |
|
||||
| --- | --- | --- |
|
||||
| `smimea_bad_usage` / `_selector` / `_match_type` | crit | Field outside the allowed range. |
|
||||
| `smimea_cert_parse_error` | crit | Hex-encoded blob is not a valid X.509 certificate / SPKI. |
|
||||
| `smimea_cert_expired` / `_not_yet_valid` | crit | `notBefore` / `notAfter` gate the current time out. |
|
||||
| `smimea_cert_expiring_soon` | warn | Within the `certExpiryWarnDays` window. |
|
||||
| `smimea_no_email_protection_eku` | crit / warn | Missing `emailProtection` EKU (RFC 8550/8551 agents will reject). |
|
||||
| `smimea_missing_key_usage` | warn | Neither `digitalSignature` nor `keyEncipherment` key-usage is set. |
|
||||
| `smimea_email_mismatch` | info | No email SAN starts with `<username>@`. |
|
||||
| `smimea_weak_signature_algorithm` | crit | MD5 / SHA-1 based signature. |
|
||||
| `smimea_weak_key_size` | crit / warn | RSA < 2048 / 3072 bits. |
|
||||
| `smimea_self_signed` | info | Self-signed certificate paired with PKIX-EE usage. |
|
||||
| `smimea_hash_only` | info | Matching-type 1/2 only carries a digest; certificate can't be inspected. |
|
||||
|
||||
## Options
|
||||
|
||||
| Id | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `resolver` | string | *(system)* | Validating resolver to query; comma-separated list accepted. |
|
||||
| `certExpiryWarnDays` | number | 30 | Raise an `expiring_soon` warning within this window. |
|
||||
| `requireDNSSEC` | bool | true | When false, missing AD is a warn instead of crit. |
|
||||
| `requireEmailProtection` | bool | true | When false, missing `emailProtection` EKU is a warn instead of crit. |
|
||||
|
||||
Auto-filled by the host: `domain_name`, `subdomain`, `service`,
|
||||
`service_type`.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Plugin (loaded by happyDomain at startup)
|
||||
make plugin
|
||||
|
||||
# Standalone HTTP server
|
||||
make && ./checker-email-keys -listen :8080
|
||||
```
|
||||
848
checker/collect.go
Normal file
848
checker/collect.go
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/dsa"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// serviceBody is the common envelope for the two services.
|
||||
type serviceBody struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
OpenPGP *dns.OPENPGPKEY `json:"openpgpkey,omitempty"`
|
||||
SMIMEA *dns.SMIMEA `json:"smimea,omitempty"`
|
||||
}
|
||||
|
||||
// Collect runs the full DANE-email testsuite and returns an *EmailKeyData
|
||||
// carrying every finding it produced. The function never returns an error
|
||||
// for domain-level problems; they are recorded as findings so that a
|
||||
// subsequent call from the rule can fold them into a single CheckState.
|
||||
// A non-nil error is returned only for unrecoverable input problems
|
||||
// (missing options, unknown service type).
|
||||
func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
svcMsg, err := serviceFromOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kind := kindForServiceType(svcMsg.Type)
|
||||
if kind == "" {
|
||||
return nil, fmt.Errorf("service type %q is not supported by this checker", svcMsg.Type)
|
||||
}
|
||||
|
||||
var body serviceBody
|
||||
if err := json.Unmarshal(svcMsg.Service, &body); err != nil {
|
||||
return nil, fmt.Errorf("decode service body: %w", err)
|
||||
}
|
||||
|
||||
originOpt, _ := sdk.GetOption[string](opts, "domain_name")
|
||||
subdomainOpt, _ := sdk.GetOption[string](opts, "subdomain")
|
||||
resolverOpt, _ := sdk.GetOption[string](opts, OptionResolver)
|
||||
expiryWarnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30)
|
||||
requireDNSSEC := sdk.GetBoolOption(opts, OptionRequireDNSSEC, true)
|
||||
requireEmailProtection := sdk.GetBoolOption(opts, OptionRequireEmailProtection, true)
|
||||
|
||||
origin := strings.TrimSuffix(firstNonEmpty(originOpt, svcMsg.Domain), ".")
|
||||
if origin == "" {
|
||||
return nil, fmt.Errorf("missing 'domain_name' option")
|
||||
}
|
||||
parent := joinSubdomain(subdomainOpt, origin)
|
||||
|
||||
data := &EmailKeyData{
|
||||
Kind: kind,
|
||||
Domain: dns.Fqdn(origin),
|
||||
Subdomain: strings.TrimSuffix(subdomainOpt, "."),
|
||||
Username: body.Username,
|
||||
CollectedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
prefix := OpenPGPKeyPrefix
|
||||
if kind == KindSMIMEA {
|
||||
prefix = SMIMEACertPrefix
|
||||
}
|
||||
expectedOwner, recordedOwner := computeOwner(body, prefix, parent)
|
||||
data.ExpectedOwner = expectedOwner
|
||||
data.QueriedOwner = firstNonEmpty(recordedOwner, expectedOwner)
|
||||
|
||||
// Username hash prefix verification (RFC 7929 §3, RFC 8162 §3).
|
||||
if data.Username != "" {
|
||||
actualPrefix, want := extractOwnerPrefix(data.QueriedOwner, prefix, parent), ownerHashHex(data.Username)
|
||||
if actualPrefix != "" && !strings.EqualFold(actualPrefix, want) {
|
||||
data.Findings = append(data.Findings, Finding{
|
||||
Code: CodeOwnerHashMismatch,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Owner name prefix %q does not match SHA-256(%q)[:28]=%q.", actualPrefix, data.Username, want),
|
||||
Fix: "Republish the record at the hash-derived name for the intended user, or update the Username field to match the record's owner name.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DNS lookup + DNSSEC flag.
|
||||
if data.QueriedOwner != "" {
|
||||
servers := resolvers(resolverOpt)
|
||||
qtype := dns.TypeOPENPGPKEY
|
||||
if kind == KindSMIMEA {
|
||||
qtype = dns.TypeSMIMEA
|
||||
}
|
||||
ans, err := lookup(ctx, servers, data.QueriedOwner, qtype)
|
||||
if err != nil {
|
||||
data.Findings = append(data.Findings, Finding{
|
||||
Code: CodeDNSQueryFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("DNS lookup for %s %s failed: %v", dns.TypeToString[qtype], data.QueriedOwner, err),
|
||||
Fix: "Check that the zone is published at an authoritative server reachable from this checker.",
|
||||
})
|
||||
} else {
|
||||
data.Resolver = ans.Server
|
||||
secure := ans.AD
|
||||
data.DNSSECSecure = &secure
|
||||
data.RecordCount = len(ans.Records)
|
||||
|
||||
if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 {
|
||||
data.Findings = append(data.Findings, Finding{
|
||||
Code: CodeDNSNoRecord,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Authoritative DNS returned no %s record at %s.", dns.TypeToString[qtype], data.QueriedOwner),
|
||||
Fix: "Ensure the record is present in the zone and that the zone has been loaded by the authoritative servers.",
|
||||
})
|
||||
} else {
|
||||
if !ans.AD {
|
||||
sev := SeverityWarn
|
||||
if requireDNSSEC {
|
||||
sev = SeverityCrit
|
||||
}
|
||||
data.Findings = append(data.Findings, Finding{
|
||||
Code: CodeDNSNotSecure,
|
||||
Severity: sev,
|
||||
Message: "The validating resolver did not set the AD flag: the record is not DNSSEC-authenticated, which defeats the whole DANE trust model.",
|
||||
Fix: "Sign the zone with DNSSEC and publish the DS record at the parent so RFC 7929/8162 consumers can authenticate the key.",
|
||||
})
|
||||
}
|
||||
// Compare observed record with the service-published one.
|
||||
mismatch := false
|
||||
if kind == KindOpenPGPKey && body.OpenPGP != nil {
|
||||
mismatch = !anyOpenPGPMatches(ans.Records, body.OpenPGP)
|
||||
} else if kind == KindSMIMEA && body.SMIMEA != nil {
|
||||
mismatch = !anySMIMEAMatches(ans.Records, body.SMIMEA)
|
||||
}
|
||||
if mismatch {
|
||||
data.Findings = append(data.Findings, Finding{
|
||||
Code: CodeDNSRecordMismatch,
|
||||
Severity: SeverityWarn,
|
||||
Message: "The record returned by DNS does not match the one declared in the service. The zone may not have been re-published since the last edit.",
|
||||
Fix: "Propagate the zone to the authoritative servers, then wait for TTL/negative-cache expiry.",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the payload from the service body (so we can analyze even if
|
||||
// DNS lookup failed to reach the authoritative servers).
|
||||
if kind == KindOpenPGPKey {
|
||||
data.OpenPGP, data.Findings = analyzeOpenPGP(body, data.Findings, time.Duration(expiryWarnDays)*24*time.Hour)
|
||||
} else {
|
||||
data.SMIMEA, data.Findings = analyzeSMIMEA(body, data.Findings, time.Duration(expiryWarnDays)*24*time.Hour, requireEmailProtection)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// serviceFromOptions pulls the "service" option out of the options map,
|
||||
// accepting both the in-process plugin path (native Go value) and the
|
||||
// HTTP path (JSON-decoded map[string]any). Normalising via a JSON
|
||||
// round-trip keeps both paths working without importing the upstream
|
||||
// type.
|
||||
func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) {
|
||||
v, ok := opts["service"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("service option missing")
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal service option: %w", err)
|
||||
}
|
||||
var svc serviceMessage
|
||||
if err := json.Unmarshal(raw, &svc); err != nil {
|
||||
return nil, fmt.Errorf("decode service option: %w", err)
|
||||
}
|
||||
// Fall back to the service_type option when the envelope doesn't
|
||||
// carry _svctype (older hosts).
|
||||
if svc.Type == "" {
|
||||
if st, ok := sdk.GetOption[string](opts, "service_type"); ok {
|
||||
svc.Type = st
|
||||
}
|
||||
}
|
||||
return &svc, nil
|
||||
}
|
||||
|
||||
func kindForServiceType(t string) string {
|
||||
switch t {
|
||||
case ServiceOpenPGP:
|
||||
return KindOpenPGPKey
|
||||
case ServiceSMimeCert:
|
||||
return KindSMIMEA
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ownerHashHex returns the RFC 7929 / 8162 label: hex(sha256(localpart)[:28]).
|
||||
func ownerHashHex(username string) string {
|
||||
sum := sha256.Sum256([]byte(username))
|
||||
return hex.EncodeToString(sum[:DANEOwnerHashSize])
|
||||
}
|
||||
|
||||
// computeOwner derives the expected FQDN from the service body. It
|
||||
// returns the expected-by-specification owner and, when the service
|
||||
// body carries its own Hdr.Name, the recorded owner, so we can detect
|
||||
// discrepancies between the two.
|
||||
func computeOwner(body serviceBody, prefix, parent string) (expected, recorded string) {
|
||||
if body.Username != "" {
|
||||
expected = dns.Fqdn(ownerHashHex(body.Username) + "." + strings.TrimPrefix(prefix, "") + "." + strings.TrimSuffix(parent, "."))
|
||||
// Normalise: no double dots.
|
||||
expected = strings.Replace(expected, "..", ".", -1)
|
||||
}
|
||||
switch {
|
||||
case body.OpenPGP != nil && body.OpenPGP.Hdr.Name != "":
|
||||
recorded = dns.Fqdn(body.OpenPGP.Hdr.Name)
|
||||
case body.SMIMEA != nil && body.SMIMEA.Hdr.Name != "":
|
||||
recorded = dns.Fqdn(body.SMIMEA.Hdr.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractOwnerPrefix pulls the leading label from an owner name of the
|
||||
// form <hash>._openpgpkey.<...> (or _smimecert), returning the hash
|
||||
// portion only. Returns "" when the owner does not follow that shape.
|
||||
func extractOwnerPrefix(owner, prefix, parent string) string {
|
||||
owner = strings.TrimSuffix(strings.ToLower(owner), ".")
|
||||
// Look for ".<prefix>." just after the first label.
|
||||
marker := "." + prefix + "."
|
||||
if i := strings.Index(owner, marker); i > 0 {
|
||||
return owner[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// anyOpenPGPMatches reports whether any of rrs carries the same public
|
||||
// key bytes as ref.
|
||||
func anyOpenPGPMatches(rrs []dns.RR, ref *dns.OPENPGPKEY) bool {
|
||||
want := strings.TrimSpace(ref.PublicKey)
|
||||
for _, rr := range rrs {
|
||||
if r, ok := rr.(*dns.OPENPGPKEY); ok && strings.TrimSpace(r.PublicKey) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// anySMIMEAMatches reports whether any of rrs matches ref on (usage,
|
||||
// selector, matching type, certificate bytes).
|
||||
func anySMIMEAMatches(rrs []dns.RR, ref *dns.SMIMEA) bool {
|
||||
want := strings.ToLower(strings.TrimSpace(ref.Certificate))
|
||||
for _, rr := range rrs {
|
||||
r, ok := rr.(*dns.SMIMEA)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if r.Usage == ref.Usage && r.Selector == ref.Selector && r.MatchingType == ref.MatchingType &&
|
||||
strings.ToLower(strings.TrimSpace(r.Certificate)) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── OpenPGP analysis ─────────────────────────────────────────────────────────
|
||||
|
||||
// analyzeOpenPGP parses the OpenPGP key from the service record and
|
||||
// emits findings. It returns the summary and the (possibly extended)
|
||||
// findings list.
|
||||
func analyzeOpenPGP(body serviceBody, findings []Finding, expiryWarn time.Duration) (*OpenPGPInfo, []Finding) {
|
||||
if body.OpenPGP == nil {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPParseError,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Service body has no OPENPGPKEY record.",
|
||||
Fix: "Attach a valid OPENPGPKEY record to the service.",
|
||||
})
|
||||
return nil, findings
|
||||
}
|
||||
|
||||
raw, err := base64.StdEncoding.DecodeString(body.OpenPGP.PublicKey)
|
||||
if err != nil {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPParseError,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("OPENPGPKEY record carries invalid base64: %v", err),
|
||||
Fix: "Re-export the public key as a binary OpenPGP packet stream (no ASCII armor) and base64 it exactly as stored in the RDATA.",
|
||||
})
|
||||
return nil, findings
|
||||
}
|
||||
|
||||
info := &OpenPGPInfo{RawSize: len(raw)}
|
||||
|
||||
// Large records get fragmented over UDP and force TCP re-queries.
|
||||
// RFC 7929 is silent on the exact threshold; >1200 bytes is a
|
||||
// reasonable "will not fit in a typical UDP answer" line.
|
||||
if len(raw) > 4096 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPRecordTooLarge,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf("The OpenPGP key packet is %d bytes. Large records force every resolver to fall back to TCP, slowing down the DANE lookup.", len(raw)),
|
||||
Fix: "Publish only the minimum key material needed for email encryption (primary + encryption subkey) and strip image UIDs / extra attributes before export.",
|
||||
})
|
||||
}
|
||||
|
||||
entities, err := openpgp.ReadKeyRing(bytes.NewReader(raw))
|
||||
if err != nil || len(entities) == 0 {
|
||||
// Fallback: try parsing as a single packet stream; some
|
||||
// implementations omit markers between entities.
|
||||
if err == nil {
|
||||
err = fmt.Errorf("no OpenPGP entity found")
|
||||
}
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPParseError,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Cannot parse OpenPGP key: %v", err),
|
||||
Fix: "Regenerate the key with `gpg --export <fpr> | base64` and paste the result; do not armor the key.",
|
||||
})
|
||||
return info, findings
|
||||
}
|
||||
|
||||
info.EntityCount = len(entities)
|
||||
if len(entities) > 1 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPMultipleEntities,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf("The record contains %d OpenPGP entities; RFC 7929 recommends a single entity per OPENPGPKEY record.", len(entities)),
|
||||
Fix: "Split each user's key into its own OPENPGPKEY RR.",
|
||||
})
|
||||
}
|
||||
|
||||
ent := entities[0]
|
||||
pub := ent.PrimaryKey
|
||||
info.CreatedAt = pub.CreationTime
|
||||
info.Fingerprint = strings.ToUpper(hex.EncodeToString(pub.Fingerprint))
|
||||
info.KeyID = fmt.Sprintf("%016X", pub.KeyId)
|
||||
info.PrimaryAlgorithm = algorithmName(pub)
|
||||
info.PrimaryBits = publicKeyBits(pub)
|
||||
|
||||
// Identity UIDs.
|
||||
for name := range ent.Identities {
|
||||
info.UIDs = append(info.UIDs, name)
|
||||
}
|
||||
|
||||
// Revocations on the primary key.
|
||||
if len(ent.Revocations) > 0 {
|
||||
info.Revoked = true
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPRevoked,
|
||||
Severity: SeverityCrit,
|
||||
Message: "The OpenPGP primary key carries a revocation signature. Consumers will refuse to encrypt to it.",
|
||||
Fix: "Publish a fresh, non-revoked key at this name, or withdraw the OPENPGPKEY record entirely.",
|
||||
})
|
||||
}
|
||||
|
||||
// Expiry on the primary key, derived from the self-signature.
|
||||
now := time.Now()
|
||||
if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil {
|
||||
if selfSig.KeyLifetimeSecs != nil && *selfSig.KeyLifetimeSecs > 0 {
|
||||
exp := pub.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second)
|
||||
info.ExpiresAt = exp
|
||||
if exp.Before(now) {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPExpired,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("The OpenPGP primary key expired on %s.", exp.Format(time.RFC3339)),
|
||||
Fix: "Extend the key's expiry (`gpg --edit-key <fpr>` → `expire`) or issue a new key and republish the OPENPGPKEY record.",
|
||||
})
|
||||
} else if expiryWarn > 0 && exp.Sub(now) < expiryWarn {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPExpiringSoon,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf("The OpenPGP primary key expires on %s.", exp.Format(time.RFC3339)),
|
||||
Fix: "Extend the key's expiry before it lapses, then re-export and republish.",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identity presence and UID vs username matching.
|
||||
if len(ent.Identities) == 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPNoIdentity,
|
||||
Severity: SeverityWarn,
|
||||
Message: "The OpenPGP key has no self-signed User ID. Most clients require at least one identity to bind the key to an email address.",
|
||||
Fix: "Add a UID containing the user's email (e.g. `gpg --edit-key <fpr>` → `adduid`) and re-export.",
|
||||
})
|
||||
} else if body.Username != "" {
|
||||
wantedLocal := strings.ToLower(body.Username)
|
||||
matched := false
|
||||
for name := range ent.Identities {
|
||||
if strings.Contains(strings.ToLower(name), "<"+wantedLocal+"@") ||
|
||||
strings.Contains(strings.ToLower(name), wantedLocal+"@") {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPUIDMismatch,
|
||||
Severity: SeverityInfo,
|
||||
Message: fmt.Sprintf("None of the OpenPGP UIDs reference <%s@…>.", body.Username),
|
||||
Fix: "Add a UID bound to the email address that the record attests to.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Primary key algorithm + size checks.
|
||||
if warn := pgpAlgorithmWarning(pub); warn != nil {
|
||||
findings = append(findings, *warn)
|
||||
}
|
||||
|
||||
// Subkeys + encryption capability.
|
||||
for _, sk := range ent.Subkeys {
|
||||
si := SubkeyInfo{
|
||||
Algorithm: algorithmName(sk.PublicKey),
|
||||
Bits: publicKeyBits(sk.PublicKey),
|
||||
CreatedAt: sk.PublicKey.CreationTime,
|
||||
Revoked: len(sk.Revocations) > 0,
|
||||
}
|
||||
if sk.Sig != nil {
|
||||
if sk.Sig.FlagsValid {
|
||||
si.CanSign = sk.Sig.FlagSign
|
||||
si.CanEncrypt = sk.Sig.FlagEncryptCommunications || sk.Sig.FlagEncryptStorage
|
||||
si.CanAuth = sk.Sig.FlagAuthenticate
|
||||
}
|
||||
if sk.Sig.KeyLifetimeSecs != nil && *sk.Sig.KeyLifetimeSecs > 0 {
|
||||
si.ExpiresAt = sk.PublicKey.CreationTime.Add(time.Duration(*sk.Sig.KeyLifetimeSecs) * time.Second)
|
||||
}
|
||||
}
|
||||
info.Subkeys = append(info.Subkeys, si)
|
||||
|
||||
if si.CanEncrypt && !si.Revoked && (si.ExpiresAt.IsZero() || si.ExpiresAt.After(now)) {
|
||||
info.HasEncryptionCapability = true
|
||||
}
|
||||
if warn := pgpAlgorithmWarning(sk.PublicKey); warn != nil {
|
||||
findings = append(findings, *warn)
|
||||
}
|
||||
}
|
||||
// Primary can also be an encryption key if flagged so.
|
||||
if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil && selfSig.FlagsValid &&
|
||||
(selfSig.FlagEncryptCommunications || selfSig.FlagEncryptStorage) &&
|
||||
!info.Revoked && (info.ExpiresAt.IsZero() || info.ExpiresAt.After(now)) {
|
||||
info.HasEncryptionCapability = true
|
||||
}
|
||||
if !info.HasEncryptionCapability {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodePGPNoEncryption,
|
||||
Severity: SeverityCrit,
|
||||
Message: "No active (non-revoked, non-expired) key in the entity advertises encryption capability. The record is useless for email encryption.",
|
||||
Fix: "Generate an encryption subkey (`gpg --edit-key <fpr>` → `addkey`) and re-export.",
|
||||
})
|
||||
}
|
||||
|
||||
return info, findings
|
||||
}
|
||||
|
||||
func algorithmName(pub *packet.PublicKey) string {
|
||||
switch pub.PubKeyAlgo {
|
||||
case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSAEncryptOnly, packet.PubKeyAlgoRSASignOnly:
|
||||
return "RSA"
|
||||
case packet.PubKeyAlgoDSA:
|
||||
return "DSA"
|
||||
case packet.PubKeyAlgoElGamal:
|
||||
return "ElGamal"
|
||||
case packet.PubKeyAlgoECDH:
|
||||
return "ECDH"
|
||||
case packet.PubKeyAlgoECDSA:
|
||||
return "ECDSA"
|
||||
case packet.PubKeyAlgoEdDSA:
|
||||
return "EdDSA"
|
||||
case packet.PubKeyAlgoX25519:
|
||||
return "X25519"
|
||||
case packet.PubKeyAlgoX448:
|
||||
return "X448"
|
||||
case packet.PubKeyAlgoEd25519:
|
||||
return "Ed25519"
|
||||
case packet.PubKeyAlgoEd448:
|
||||
return "Ed448"
|
||||
default:
|
||||
return fmt.Sprintf("algo-%d", pub.PubKeyAlgo)
|
||||
}
|
||||
}
|
||||
|
||||
func publicKeyBits(pub *packet.PublicKey) int {
|
||||
if pub == nil {
|
||||
return 0
|
||||
}
|
||||
switch k := pub.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
if k == nil || k.N == nil {
|
||||
return 0
|
||||
}
|
||||
return k.N.BitLen()
|
||||
case *dsa.PublicKey:
|
||||
if k == nil || k.P == nil {
|
||||
return 0
|
||||
}
|
||||
return k.P.BitLen()
|
||||
case *ecdsa.PublicKey:
|
||||
if k == nil || k.Params() == nil {
|
||||
return 0
|
||||
}
|
||||
return k.Params().BitSize
|
||||
case ed25519.PublicKey:
|
||||
return 256
|
||||
}
|
||||
// Fallback to the packet's advertised length.
|
||||
if n, err := pub.BitLength(); err == nil {
|
||||
return int(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func pgpAlgorithmWarning(pub *packet.PublicKey) *Finding {
|
||||
switch pub.PubKeyAlgo {
|
||||
case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSAEncryptOnly, packet.PubKeyAlgoRSASignOnly:
|
||||
bits := publicKeyBits(pub)
|
||||
if bits == 0 {
|
||||
return nil
|
||||
}
|
||||
if bits < 2048 {
|
||||
return &Finding{
|
||||
Code: CodePGPWeakKeySize,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("RSA key of %d bits is considered broken. NIST SP 800-131A deprecates anything below 2048 bits.", bits),
|
||||
Fix: "Generate a fresh RSA-3072/4096 or Ed25519/Curve25519 key and republish.",
|
||||
}
|
||||
}
|
||||
if bits < 3072 {
|
||||
return &Finding{
|
||||
Code: CodePGPWeakKeySize,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf("RSA-%d is aging; NIST recommends at least 3072 bits for new deployments.", bits),
|
||||
Fix: "Plan a migration to RSA-3072/4096 or Ed25519/Curve25519 at the next key rotation.",
|
||||
}
|
||||
}
|
||||
case packet.PubKeyAlgoDSA, packet.PubKeyAlgoElGamal:
|
||||
return &Finding{
|
||||
Code: CodePGPWeakAlgorithm,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf("Primary/subkey uses %s, which modern OpenPGP stacks are phasing out.", algorithmName(pub)),
|
||||
Fix: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── SMIMEA analysis ──────────────────────────────────────────────────────────
|
||||
|
||||
// analyzeSMIMEA parses the SMIMEA certificate, computes a structured
|
||||
// summary, and emits findings.
|
||||
func analyzeSMIMEA(body serviceBody, findings []Finding, expiryWarn time.Duration, requireEmailProtection bool) (*SMIMEAInfo, []Finding) {
|
||||
if body.SMIMEA == nil {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEACertParseError,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Service body has no SMIMEA record.",
|
||||
Fix: "Attach a valid SMIMEA record to the service.",
|
||||
})
|
||||
return nil, findings
|
||||
}
|
||||
rec := body.SMIMEA
|
||||
|
||||
info := &SMIMEAInfo{
|
||||
Usage: rec.Usage,
|
||||
Selector: rec.Selector,
|
||||
MatchingType: rec.MatchingType,
|
||||
HashHex: strings.ToLower(rec.Certificate),
|
||||
}
|
||||
|
||||
// Usage (RFC 6698 + 8162): 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE.
|
||||
if rec.Usage > 3 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEABadUsage,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Unknown SMIMEA usage %d (expected 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE).", rec.Usage),
|
||||
Fix: "Use usage 3 (DANE-EE) for self-hosted S/MIME certificates.",
|
||||
})
|
||||
}
|
||||
if rec.Selector > 1 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEABadSelector,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Unknown SMIMEA selector %d (expected 0 Cert or 1 SPKI).", rec.Selector),
|
||||
Fix: "Use selector 0 to publish the full certificate.",
|
||||
})
|
||||
}
|
||||
if rec.MatchingType > 2 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEABadMatchType,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Unknown SMIMEA matching type %d (expected 0 Full, 1 SHA-256, 2 SHA-512).", rec.MatchingType),
|
||||
Fix: "Use matching type 0 so the whole certificate is transported, or type 1 (SHA-256) for a digest.",
|
||||
})
|
||||
}
|
||||
|
||||
// Matching types 1 and 2 only carry a digest; no certificate to
|
||||
// parse. Surface that as info so the user knows the checker's
|
||||
// findings are limited.
|
||||
if rec.MatchingType != 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEAHashOnly,
|
||||
Severity: SeverityInfo,
|
||||
Message: "Record carries only a digest; the certificate itself cannot be verified by this checker.",
|
||||
Fix: "Switch to matching type 0 (Full) to let verifiers inspect and pin the certificate.",
|
||||
})
|
||||
return info, findings
|
||||
}
|
||||
|
||||
der, err := hex.DecodeString(rec.Certificate)
|
||||
if err != nil || len(der) == 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEACertParseError,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Cannot decode certificate bytes: %v", err),
|
||||
Fix: "Re-export the certificate as DER and hex-encode it into the SMIMEA RDATA.",
|
||||
})
|
||||
return info, findings
|
||||
}
|
||||
|
||||
// Selector 1 carries only a SubjectPublicKeyInfo; parse it that way.
|
||||
if rec.Selector == 1 {
|
||||
info.PublicKey = analyzeSPKI(der, &findings)
|
||||
return info, findings
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
// Try a PEM fallback for robustness.
|
||||
if block, _ := pem.Decode(der); block != nil && block.Type == "CERTIFICATE" {
|
||||
cert, err = x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
}
|
||||
if err != nil || cert == nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("no certificate found")
|
||||
}
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEACertParseError,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Cannot parse X.509 certificate: %v", err),
|
||||
Fix: "Ensure the certificate is DER-encoded (not PEM) before hex-encoding it into SMIMEA RDATA.",
|
||||
})
|
||||
return info, findings
|
||||
}
|
||||
|
||||
ci := &CertInfo{
|
||||
Subject: cert.Subject.String(),
|
||||
Issuer: cert.Issuer.String(),
|
||||
SerialHex: strings.ToUpper(hex.EncodeToString(cert.SerialNumber.Bytes())),
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
SignatureAlgorithm: cert.SignatureAlgorithm.String(),
|
||||
PublicKeyAlgorithm: cert.PublicKeyAlgorithm.String(),
|
||||
EmailAddresses: cert.EmailAddresses,
|
||||
DNSNames: cert.DNSNames,
|
||||
IsCA: cert.IsCA,
|
||||
}
|
||||
ci.IsSelfSigned = cert.Subject.String() == cert.Issuer.String() && cert.CheckSignatureFrom(cert) == nil
|
||||
ci.PublicKeyBits = x509PublicKeyBits(cert.PublicKey)
|
||||
|
||||
for _, eku := range cert.ExtKeyUsage {
|
||||
if eku == x509.ExtKeyUsageEmailProtection {
|
||||
ci.HasEmailProtectionEKU = true
|
||||
}
|
||||
}
|
||||
if cert.KeyUsage&x509.KeyUsageDigitalSignature != 0 {
|
||||
ci.HasDigitalSignature = true
|
||||
}
|
||||
if cert.KeyUsage&x509.KeyUsageKeyEncipherment != 0 {
|
||||
ci.HasKeyEncipherment = true
|
||||
}
|
||||
|
||||
info.Certificate = ci
|
||||
|
||||
now := time.Now()
|
||||
if now.Before(cert.NotBefore) {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEACertNotYetValid,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Certificate is not yet valid (NotBefore = %s).", cert.NotBefore.Format(time.RFC3339)),
|
||||
Fix: "Check the system clock on the CA/signer, or wait until the certificate's notBefore date.",
|
||||
})
|
||||
}
|
||||
if now.After(cert.NotAfter) {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEACertExpired,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Certificate expired on %s.", cert.NotAfter.Format(time.RFC3339)),
|
||||
Fix: "Issue a fresh certificate and republish the SMIMEA record.",
|
||||
})
|
||||
} else if expiryWarn > 0 && cert.NotAfter.Sub(now) < expiryWarn {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEACertExpiringSoon,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf("Certificate expires on %s.", cert.NotAfter.Format(time.RFC3339)),
|
||||
Fix: "Renew before expiry and update the SMIMEA record with the new certificate.",
|
||||
})
|
||||
}
|
||||
|
||||
if !ci.HasEmailProtectionEKU {
|
||||
sev := SeverityWarn
|
||||
if requireEmailProtection {
|
||||
sev = SeverityCrit
|
||||
}
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEANoEmailProtection,
|
||||
Severity: sev,
|
||||
Message: "Certificate lacks the emailProtection Extended Key Usage; RFC 8550/8551 agents will refuse it.",
|
||||
Fix: "Re-issue the certificate with `extendedKeyUsage = emailProtection` (OID 1.3.6.1.5.5.7.3.4).",
|
||||
})
|
||||
}
|
||||
if !ci.HasDigitalSignature && !ci.HasKeyEncipherment {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEANoKeyUsage,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Certificate has neither digitalSignature nor keyEncipherment key usage; S/MIME signing or encryption will be refused.",
|
||||
Fix: "Add `keyUsage = digitalSignature, keyEncipherment` to the certificate profile.",
|
||||
})
|
||||
}
|
||||
|
||||
// Weak signature algorithms (MD5/SHA-1 based signatures).
|
||||
switch cert.SignatureAlgorithm {
|
||||
case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA,
|
||||
x509.DSAWithSHA1, x509.ECDSAWithSHA1:
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEAWeakSignatureAlg,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Certificate is signed with %s, a deprecated algorithm.", cert.SignatureAlgorithm),
|
||||
Fix: "Re-issue the certificate with SHA-256 (or better) signatures.",
|
||||
})
|
||||
}
|
||||
|
||||
// Weak key sizes.
|
||||
if _, isRSA := cert.PublicKey.(*rsa.PublicKey); isRSA && ci.PublicKeyBits > 0 {
|
||||
if ci.PublicKeyBits < 2048 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEAWeakKeySize,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("RSA key is %d bits (below the 2048-bit minimum).", ci.PublicKeyBits),
|
||||
Fix: "Re-issue the certificate with an RSA-3072 or ECDSA-P256 key.",
|
||||
})
|
||||
} else if ci.PublicKeyBits < 3072 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEAWeakKeySize,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf("RSA key is %d bits; prefer 3072+ for new deployments.", ci.PublicKeyBits),
|
||||
Fix: "Plan a rotation to RSA-3072+ or ECDSA at the next certificate renewal.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Self-signed certificate flagged for EE usages. For TA usages (0/2)
|
||||
// self-signed is expected.
|
||||
if ci.IsSelfSigned && (rec.Usage == 1 || rec.Usage == 3) {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEASelfSigned,
|
||||
Severity: SeverityInfo,
|
||||
Message: "End-entity usage advertises a self-signed certificate; DANE-EE (usage 3) makes this safe, but PKIX-EE (usage 1) consumers will reject it.",
|
||||
Fix: "Switch the record to usage 3 (DANE-EE) if you operate your own CA, or chain the certificate under a public CA for usage 1.",
|
||||
})
|
||||
}
|
||||
|
||||
// Email-address / username pairing.
|
||||
if body.Username != "" {
|
||||
wantPrefix := strings.ToLower(body.Username) + "@"
|
||||
matched := false
|
||||
for _, e := range cert.EmailAddresses {
|
||||
if strings.HasPrefix(strings.ToLower(e), wantPrefix) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched && len(cert.EmailAddresses) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeSMIMEAEmailMismatch,
|
||||
Severity: SeverityInfo,
|
||||
Message: fmt.Sprintf("None of the certificate's email SANs (%s) begin with %s; clients that strictly match SAN to envelope address will reject it.", strings.Join(cert.EmailAddresses, ", "), body.Username+"@"),
|
||||
Fix: "Re-issue the certificate with the correct `subjectAltName = email:<user>@<domain>`.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return info, findings
|
||||
}
|
||||
|
||||
func analyzeSPKI(der []byte, findings *[]Finding) *PubKeyInfo {
|
||||
pub, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
*findings = append(*findings, Finding{
|
||||
Code: CodeSMIMEACertParseError,
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Cannot parse SubjectPublicKeyInfo: %v", err),
|
||||
Fix: "Ensure the SMIMEA selector=1 record carries a DER-encoded SPKI (not a full certificate).",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
info := &PubKeyInfo{Bits: x509PublicKeyBits(pub)}
|
||||
switch pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
info.Algorithm = "RSA"
|
||||
case *ecdsa.PublicKey:
|
||||
info.Algorithm = "ECDSA"
|
||||
case ed25519.PublicKey:
|
||||
info.Algorithm = "Ed25519"
|
||||
default:
|
||||
info.Algorithm = fmt.Sprintf("%T", pub)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func x509PublicKeyBits(pub any) int {
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
if k == nil || k.N == nil {
|
||||
return 0
|
||||
}
|
||||
return k.N.BitLen()
|
||||
case *ecdsa.PublicKey:
|
||||
if k == nil || k.Params() == nil {
|
||||
return 0
|
||||
}
|
||||
return k.Params().BitSize
|
||||
case ed25519.PublicKey:
|
||||
return 256
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
101
checker/definition.go
Normal file
101
checker/definition.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version defaults to "built-in"; standalone and plugin builds override
|
||||
// it via -ldflags "-X .../checker.Version=...".
|
||||
var Version = "built-in"
|
||||
|
||||
// Option ids.
|
||||
const (
|
||||
OptionResolver = "resolver"
|
||||
OptionCertExpiryWarnDays = "certExpiryWarnDays"
|
||||
OptionRequireDNSSEC = "requireDNSSEC"
|
||||
OptionRequireEmailProtection = "requireEmailProtection"
|
||||
)
|
||||
|
||||
// Definition is the package-level helper returned to the host by the
|
||||
// plugin entrypoint and used by sdk.NewServer via the provider's
|
||||
// CheckerDefinitionProvider implementation.
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "openpgpkey-smimea",
|
||||
Name: "OPENPGPKEY & SMIMEA",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{
|
||||
ServiceOpenPGP,
|
||||
ServiceSMimeCert,
|
||||
},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKey},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: OptionResolver,
|
||||
Type: "string",
|
||||
Label: "DNS resolver",
|
||||
Placeholder: "1.1.1.1",
|
||||
Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.",
|
||||
},
|
||||
{
|
||||
Id: OptionCertExpiryWarnDays,
|
||||
Type: "number",
|
||||
Label: "Expiry warning threshold (days)",
|
||||
Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.",
|
||||
Default: float64(30),
|
||||
},
|
||||
{
|
||||
Id: OptionRequireDNSSEC,
|
||||
Type: "bool",
|
||||
Label: "Require DNSSEC",
|
||||
Description: "When enabled, a non-DNSSEC-validated lookup is reported as critical (otherwise as warning). RFC 7929 and RFC 8162 mandate DNSSEC.",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Id: OptionRequireEmailProtection,
|
||||
Type: "bool",
|
||||
Label: "Require emailProtection EKU",
|
||||
Description: "When enabled, an S/MIME certificate without the emailProtection Extended Key Usage is reported as critical.",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Label: "Zone origin",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
},
|
||||
{
|
||||
Id: "subdomain",
|
||||
Label: "Subdomain",
|
||||
AutoFill: sdk.AutoFillSubdomain,
|
||||
},
|
||||
},
|
||||
ServiceOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "service",
|
||||
Label: "Service",
|
||||
AutoFill: sdk.AutoFillService,
|
||||
},
|
||||
{
|
||||
Id: "service_type",
|
||||
Label: "Service type",
|
||||
AutoFill: sdk.AutoFillServiceType,
|
||||
Hide: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{Rule()},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 1 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 12 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
121
checker/dns.go
Normal file
121
checker/dns.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// dnsTimeout is the per-query deadline used by every helper here.
|
||||
const dnsTimeout = 5 * time.Second
|
||||
|
||||
// dnsLookupAnswer is the subset of a DNS answer this checker cares about.
|
||||
type dnsLookupAnswer struct {
|
||||
// Records are the answer records of the requested type.
|
||||
Records []dns.RR
|
||||
// AD reports whether the response header has the Authenticated Data
|
||||
// flag set, i.e. the validating resolver confirmed DNSSEC.
|
||||
AD bool
|
||||
// Rcode is the response code from the answering resolver.
|
||||
Rcode int
|
||||
// Server is the address of the resolver that answered.
|
||||
Server string
|
||||
}
|
||||
|
||||
// resolvers returns the list of resolver addresses to try. If resolverOpt
|
||||
// is non-empty it is parsed (comma-separated allowed) into a host:port
|
||||
// list. Otherwise /etc/resolv.conf is read; if that fails we fall back to
|
||||
// public validating resolvers.
|
||||
func resolvers(resolverOpt string) []string {
|
||||
if s := strings.TrimSpace(resolverOpt); s != "" {
|
||||
var out []string
|
||||
for part := range strings.SplitSeq(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(part, ":") {
|
||||
part = net.JoinHostPort(part, "53")
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
if cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf"); err == nil && cfg != nil && len(cfg.Servers) > 0 {
|
||||
out := make([]string, 0, len(cfg.Servers))
|
||||
for _, s := range cfg.Servers {
|
||||
out = append(out, net.JoinHostPort(s, cfg.Port))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Fall back to known validating resolvers.
|
||||
return []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}
|
||||
}
|
||||
|
||||
// lookup queries qtype at owner against each resolver in order until one
|
||||
// answers. The first resolver whose answer has a non-error Rcode wins.
|
||||
// DNSSEC validation is requested via EDNS0 DO=1; the AD flag is read back
|
||||
// from the response header.
|
||||
func lookup(ctx context.Context, servers []string, owner string, qtype uint16) (*dnsLookupAnswer, error) {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(owner), qtype)
|
||||
m.SetEdns0(4096, true)
|
||||
m.RecursionDesired = true
|
||||
m.AuthenticatedData = true
|
||||
|
||||
c := &dns.Client{Timeout: dnsTimeout}
|
||||
|
||||
var lastErr error
|
||||
for _, srv := range servers {
|
||||
in, _, err := c.ExchangeContext(ctx, m, srv)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if in == nil {
|
||||
lastErr = fmt.Errorf("nil response from %s", srv)
|
||||
continue
|
||||
}
|
||||
ans := &dnsLookupAnswer{
|
||||
Rcode: in.Rcode,
|
||||
AD: in.AuthenticatedData,
|
||||
Server: srv,
|
||||
}
|
||||
for _, rr := range in.Answer {
|
||||
if rr.Header().Rrtype == qtype {
|
||||
ans.Records = append(ans.Records, rr)
|
||||
}
|
||||
}
|
||||
if in.Rcode == dns.RcodeSuccess || in.Rcode == dns.RcodeNameError {
|
||||
return ans, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("rcode %s from %s", dns.RcodeToString[in.Rcode], srv)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no resolver available")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// joinSubdomain composes the FQDN of a subdomain within a zone. Both
|
||||
// arguments are accepted in any of their canonical forms (trailing dot
|
||||
// optional, empty subdomain allowed).
|
||||
func joinSubdomain(subdomain, origin string) string {
|
||||
origin = strings.TrimSuffix(origin, ".")
|
||||
subdomain = strings.TrimSuffix(subdomain, ".")
|
||||
if subdomain == "" || subdomain == "@" {
|
||||
return dns.Fqdn(origin)
|
||||
}
|
||||
if strings.HasSuffix(subdomain, "."+origin) || subdomain == origin {
|
||||
return dns.Fqdn(subdomain)
|
||||
}
|
||||
return dns.Fqdn(subdomain + "." + origin)
|
||||
}
|
||||
171
checker/interactive.go
Normal file
171
checker/interactive.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderForm implements sdk.CheckerInteractive. It exposes the minimal
|
||||
// inputs needed to bootstrap a standalone OPENPGPKEY/SMIMEA check: an
|
||||
// email address (the local part is hashed into the owner name) and a
|
||||
// kind selector. The DNS resolver and severity-tuning options mirror
|
||||
// the regular UserOpts so a human can override them on the form.
|
||||
func (p *emailKeyProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "email",
|
||||
Type: "string",
|
||||
Label: "Email address",
|
||||
Placeholder: "alice@example.com",
|
||||
Description: "Address to look up. The local part is SHA-256-hashed per RFC 7929/8162; the domain part is the zone queried.",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "kind",
|
||||
Type: "string",
|
||||
Label: "Record kind",
|
||||
Default: KindOpenPGPKey,
|
||||
Choices: []string{KindOpenPGPKey, KindSMIMEA},
|
||||
},
|
||||
{
|
||||
Id: OptionResolver,
|
||||
Type: "string",
|
||||
Label: "DNS resolver",
|
||||
Placeholder: "1.1.1.1",
|
||||
Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.",
|
||||
},
|
||||
{
|
||||
Id: OptionCertExpiryWarnDays,
|
||||
Type: "number",
|
||||
Label: "Expiry warning threshold (days)",
|
||||
Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.",
|
||||
Default: float64(30),
|
||||
},
|
||||
{
|
||||
Id: OptionRequireDNSSEC,
|
||||
Type: "bool",
|
||||
Label: "Require DNSSEC",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Id: OptionRequireEmailProtection,
|
||||
Type: "bool",
|
||||
Label: "Require emailProtection EKU (SMIMEA only)",
|
||||
Default: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseForm implements sdk.CheckerInteractive. It validates the inputs,
|
||||
// resolves the DNS record matching the requested kind, and returns the
|
||||
// CheckerOptions that Collect expects, including a synthesised service
|
||||
// envelope built from the live DNS answer.
|
||||
func (p *emailKeyProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("email is required")
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at <= 0 || at == len(email)-1 {
|
||||
return nil, fmt.Errorf("email %q must be of the form local@domain", email)
|
||||
}
|
||||
username := email[:at]
|
||||
domain := strings.TrimSuffix(strings.ToLower(email[at+1:]), ".")
|
||||
|
||||
kind := strings.TrimSpace(r.FormValue("kind"))
|
||||
if kind == "" {
|
||||
kind = KindOpenPGPKey
|
||||
}
|
||||
var (
|
||||
svcType string
|
||||
prefix string
|
||||
qtype uint16
|
||||
)
|
||||
switch kind {
|
||||
case KindOpenPGPKey:
|
||||
svcType = ServiceOpenPGP
|
||||
prefix = OpenPGPKeyPrefix
|
||||
qtype = dns.TypeOPENPGPKEY
|
||||
case KindSMIMEA:
|
||||
svcType = ServiceSMimeCert
|
||||
prefix = SMIMEACertPrefix
|
||||
qtype = dns.TypeSMIMEA
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown kind %q (expected %q or %q)", kind, KindOpenPGPKey, KindSMIMEA)
|
||||
}
|
||||
|
||||
resolverOpt := strings.TrimSpace(r.FormValue(OptionResolver))
|
||||
owner := dns.Fqdn(ownerHashHex(username) + "." + strings.TrimPrefix(prefix, ".") + "." + domain)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), dnsTimeout*3)
|
||||
defer cancel()
|
||||
|
||||
ans, err := lookup(ctx, resolvers(resolverOpt), owner, qtype)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DNS lookup for %s %s failed: %w", dns.TypeToString[qtype], owner, err)
|
||||
}
|
||||
if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 {
|
||||
return nil, fmt.Errorf("no %s record found at %s", dns.TypeToString[qtype], owner)
|
||||
}
|
||||
|
||||
body := serviceBody{Username: username}
|
||||
switch kind {
|
||||
case KindOpenPGPKey:
|
||||
rr, ok := ans.Records[0].(*dns.OPENPGPKEY)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected record type %T at %s", ans.Records[0], owner)
|
||||
}
|
||||
body.OpenPGP = rr
|
||||
case KindSMIMEA:
|
||||
rr, ok := ans.Records[0].(*dns.SMIMEA)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected record type %T at %s", ans.Records[0], owner)
|
||||
}
|
||||
body.SMIMEA = rr
|
||||
}
|
||||
|
||||
bodyJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode service body: %w", err)
|
||||
}
|
||||
svcMsg := serviceMessage{
|
||||
Type: svcType,
|
||||
Domain: dns.Fqdn(domain),
|
||||
Service: bodyJSON,
|
||||
}
|
||||
|
||||
opts := sdk.CheckerOptions{
|
||||
"service": svcMsg,
|
||||
"service_type": svcType,
|
||||
"domain_name": domain,
|
||||
}
|
||||
if resolverOpt != "" {
|
||||
opts[OptionResolver] = resolverOpt
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue(OptionCertExpiryWarnDays)); v != "" {
|
||||
opts[OptionCertExpiryWarnDays] = parseFloatOr(v, 30)
|
||||
}
|
||||
opts[OptionRequireDNSSEC] = r.FormValue(OptionRequireDNSSEC) == "true"
|
||||
opts[OptionRequireEmailProtection] = r.FormValue(OptionRequireEmailProtection) == "true"
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// parseFloatOr parses a decimal string, returning fallback on error.
|
||||
func parseFloatOr(s string, fallback float64) float64 {
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(s, "%f", &f); err != nil {
|
||||
return fallback
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Compile-time assertion that the provider implements the optional interface.
|
||||
var _ sdk.CheckerInteractive = (*emailKeyProvider)(nil)
|
||||
23
checker/provider.go
Normal file
23
checker/provider.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns a new OPENPGPKEY/SMIMEA observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &emailKeyProvider{}
|
||||
}
|
||||
|
||||
type emailKeyProvider struct{}
|
||||
|
||||
// Key implements sdk.ObservationProvider.
|
||||
func (p *emailKeyProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKey
|
||||
}
|
||||
|
||||
// Definition implements sdk.CheckerDefinitionProvider so the SDK server
|
||||
// can expose /definition without an extra argument.
|
||||
func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition {
|
||||
return Definition()
|
||||
}
|
||||
674
checker/report.go
Normal file
674
checker/report.go
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// remediation is a single actionable hint shown in the report's
|
||||
// "most common issues, fix these first" banner. Bodies are rendered
|
||||
// with template.HTML so each remediation can ship its own markup
|
||||
// (pre-formatted code snippets, lists, links).
|
||||
type remediation struct {
|
||||
Title string
|
||||
Body template.HTML
|
||||
}
|
||||
|
||||
// findingRow models a single row in the full findings table.
|
||||
type findingRow struct {
|
||||
Code string
|
||||
Severity string
|
||||
Message string
|
||||
Fix string
|
||||
}
|
||||
|
||||
// subkeyRow mirrors SubkeyInfo for the template, with pre-formatted
|
||||
// times and a Capabilities string.
|
||||
type subkeyRow struct {
|
||||
Algorithm string
|
||||
Bits int
|
||||
Capabilities string
|
||||
Created string
|
||||
Expires string
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
// reportData is the template context.
|
||||
type reportData struct {
|
||||
Kind string
|
||||
Headline string
|
||||
Badge string // "ok" / "warn" / "fail" / "neutral"
|
||||
QueriedOwner string
|
||||
ExpectedOwner string
|
||||
Resolver string
|
||||
DNSSEC string // "secure" / "insecure" / "unknown"
|
||||
RecordCount int
|
||||
Username string
|
||||
CollectedAt string
|
||||
|
||||
OpenPGP *openPGPView
|
||||
SMIMEA *smimeaView
|
||||
|
||||
Remediations []remediation
|
||||
Findings []findingRow
|
||||
CritCount int
|
||||
WarnCount int
|
||||
InfoCount int
|
||||
}
|
||||
|
||||
type openPGPView struct {
|
||||
Fingerprint string
|
||||
KeyID string
|
||||
Algorithm string
|
||||
Bits int
|
||||
UIDs []string
|
||||
Created string
|
||||
Expires string
|
||||
Revoked bool
|
||||
Encrypt bool
|
||||
Subkeys []subkeyRow
|
||||
RawSize int
|
||||
EntityCount int
|
||||
}
|
||||
|
||||
type smimeaView struct {
|
||||
Usage string
|
||||
Selector string
|
||||
MatchingType string
|
||||
HashOnly bool
|
||||
HashHex string
|
||||
Subject string
|
||||
Issuer string
|
||||
Serial string
|
||||
NotBefore string
|
||||
NotAfter string
|
||||
SignatureAlgo string
|
||||
KeyAlgo string
|
||||
Bits int
|
||||
Emails []string
|
||||
DNSNames []string
|
||||
EmailProtection bool
|
||||
DigitalSignature bool
|
||||
KeyEncipherment bool
|
||||
SelfSigned bool
|
||||
IsCA bool
|
||||
}
|
||||
|
||||
// GetHTMLReport implements sdk.CheckerHTMLReporter.
|
||||
func (p *emailKeyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var data EmailKeyData
|
||||
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
|
||||
return "", fmt.Errorf("unmarshal report data: %w", err)
|
||||
}
|
||||
|
||||
rd := buildReportData(&data)
|
||||
|
||||
var buf strings.Builder
|
||||
if err := reportTemplate.Execute(&buf, rd); err != nil {
|
||||
return "", fmt.Errorf("render report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func buildReportData(d *EmailKeyData) reportData {
|
||||
rd := reportData{
|
||||
Kind: d.Kind,
|
||||
QueriedOwner: d.QueriedOwner,
|
||||
ExpectedOwner: d.ExpectedOwner,
|
||||
Resolver: d.Resolver,
|
||||
RecordCount: d.RecordCount,
|
||||
Username: d.Username,
|
||||
CollectedAt: d.CollectedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
switch {
|
||||
case d.DNSSECSecure == nil:
|
||||
rd.DNSSEC = "unknown"
|
||||
case *d.DNSSECSecure:
|
||||
rd.DNSSEC = "secure"
|
||||
default:
|
||||
rd.DNSSEC = "insecure"
|
||||
}
|
||||
|
||||
// Sort findings by severity (crit first) for stable display.
|
||||
findings := make([]Finding, len(d.Findings))
|
||||
copy(findings, d.Findings)
|
||||
sort.SliceStable(findings, func(i, j int) bool {
|
||||
return severityRank(findings[i].Severity) > severityRank(findings[j].Severity)
|
||||
})
|
||||
for _, f := range findings {
|
||||
rd.Findings = append(rd.Findings, findingRow{
|
||||
Code: f.Code, Severity: string(f.Severity), Message: f.Message, Fix: f.Fix,
|
||||
})
|
||||
switch f.Severity {
|
||||
case SeverityCrit:
|
||||
rd.CritCount++
|
||||
case SeverityWarn:
|
||||
rd.WarnCount++
|
||||
case SeverityInfo:
|
||||
rd.InfoCount++
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case rd.CritCount > 0:
|
||||
rd.Badge = "fail"
|
||||
rd.Headline = fmt.Sprintf("%d critical issue(s) found", rd.CritCount)
|
||||
case rd.WarnCount > 0:
|
||||
rd.Badge = "warn"
|
||||
rd.Headline = fmt.Sprintf("%d warning(s)", rd.WarnCount)
|
||||
case rd.InfoCount > 0:
|
||||
rd.Badge = "neutral"
|
||||
rd.Headline = "Informational findings"
|
||||
default:
|
||||
rd.Badge = "ok"
|
||||
rd.Headline = "All checks passed"
|
||||
}
|
||||
|
||||
if d.Kind == KindOpenPGPKey && d.OpenPGP != nil {
|
||||
rd.OpenPGP = buildOpenPGPView(d.OpenPGP)
|
||||
}
|
||||
if d.Kind == KindSMIMEA && d.SMIMEA != nil {
|
||||
rd.SMIMEA = buildSMIMEAView(d.SMIMEA)
|
||||
}
|
||||
|
||||
rd.Remediations = buildRemediations(d)
|
||||
|
||||
return rd
|
||||
}
|
||||
|
||||
func buildOpenPGPView(o *OpenPGPInfo) *openPGPView {
|
||||
v := &openPGPView{
|
||||
Fingerprint: formatFingerprint(o.Fingerprint),
|
||||
KeyID: o.KeyID,
|
||||
Algorithm: o.PrimaryAlgorithm,
|
||||
Bits: o.PrimaryBits,
|
||||
UIDs: append([]string(nil), o.UIDs...),
|
||||
Created: fmtTime(o.CreatedAt),
|
||||
Expires: fmtTime(o.ExpiresAt),
|
||||
Revoked: o.Revoked,
|
||||
Encrypt: o.HasEncryptionCapability,
|
||||
RawSize: o.RawSize,
|
||||
EntityCount: o.EntityCount,
|
||||
}
|
||||
if v.Expires == "" {
|
||||
v.Expires = "never"
|
||||
}
|
||||
sort.Strings(v.UIDs)
|
||||
for _, sk := range o.Subkeys {
|
||||
caps := subkeyCaps(sk)
|
||||
v.Subkeys = append(v.Subkeys, subkeyRow{
|
||||
Algorithm: sk.Algorithm,
|
||||
Bits: sk.Bits,
|
||||
Capabilities: caps,
|
||||
Created: fmtTime(sk.CreatedAt),
|
||||
Expires: fmtTimeOrNever(sk.ExpiresAt),
|
||||
Revoked: sk.Revoked,
|
||||
})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func buildSMIMEAView(s *SMIMEAInfo) *smimeaView {
|
||||
v := &smimeaView{
|
||||
Usage: smimeaUsageName(s.Usage),
|
||||
Selector: smimeaSelectorName(s.Selector),
|
||||
MatchingType: smimeaMatchingTypeName(s.MatchingType),
|
||||
HashOnly: s.MatchingType != 0,
|
||||
HashHex: s.HashHex,
|
||||
}
|
||||
if s.Certificate != nil {
|
||||
c := s.Certificate
|
||||
v.Subject = c.Subject
|
||||
v.Issuer = c.Issuer
|
||||
v.Serial = c.SerialHex
|
||||
v.NotBefore = fmtTime(c.NotBefore)
|
||||
v.NotAfter = fmtTime(c.NotAfter)
|
||||
v.SignatureAlgo = c.SignatureAlgorithm
|
||||
v.KeyAlgo = c.PublicKeyAlgorithm
|
||||
v.Bits = c.PublicKeyBits
|
||||
v.Emails = append([]string(nil), c.EmailAddresses...)
|
||||
v.DNSNames = append([]string(nil), c.DNSNames...)
|
||||
v.EmailProtection = c.HasEmailProtectionEKU
|
||||
v.DigitalSignature = c.HasDigitalSignature
|
||||
v.KeyEncipherment = c.HasKeyEncipherment
|
||||
v.SelfSigned = c.IsSelfSigned
|
||||
v.IsCA = c.IsCA
|
||||
}
|
||||
if s.PublicKey != nil && v.KeyAlgo == "" {
|
||||
v.KeyAlgo = s.PublicKey.Algorithm
|
||||
v.Bits = s.PublicKey.Bits
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// buildRemediations detects the most common failure scenarios and
|
||||
// surfaces a focused, user-actionable card for each. Only matching
|
||||
// issues produce a remediation; a clean run shows none.
|
||||
func buildRemediations(d *EmailKeyData) []remediation {
|
||||
var out []remediation
|
||||
|
||||
byCode := map[string]Finding{}
|
||||
for _, f := range d.Findings {
|
||||
// Keep the first (most severe after sort) finding per code.
|
||||
if _, ok := byCode[f.Code]; !ok {
|
||||
byCode[f.Code] = f
|
||||
}
|
||||
}
|
||||
|
||||
pick := func(code, title, body string) {
|
||||
if _, ok := byCode[code]; !ok {
|
||||
return
|
||||
}
|
||||
out = append(out, remediation{Title: title, Body: template.HTML(body)})
|
||||
}
|
||||
|
||||
pick(CodeDNSNoRecord,
|
||||
"Publish the record in DNS",
|
||||
fmt.Sprintf(`No <code>%s</code> record resolves at <code>%s</code>. Publish it in the zone and reload the authoritative servers.<br><br>
|
||||
Quick checklist:
|
||||
<ol>
|
||||
<li>Verify the owner name: <code>sha256(localpart)[0:28] . %s . %s</code>.</li>
|
||||
<li>Confirm the record reached your signer by running <code>dig +dnssec %s %s @<auth-ns></code>.</li>
|
||||
<li>Wait for TTL expiry if the record was only recently published.</li>
|
||||
</ol>`,
|
||||
kindRRType(d.Kind),
|
||||
template.HTMLEscapeString(d.QueriedOwner),
|
||||
template.HTMLEscapeString(kindPrefix(d.Kind)),
|
||||
template.HTMLEscapeString(strings.TrimSuffix(d.Domain, ".")),
|
||||
kindRRType(d.Kind),
|
||||
template.HTMLEscapeString(d.QueriedOwner)))
|
||||
|
||||
pick(CodeDNSNotSecure,
|
||||
"Enable DNSSEC on the zone",
|
||||
`RFC 7929 and RFC 8162 only grant authority to the key/certificate when DNSSEC validates it. Without DNSSEC, an attacker on the network path can substitute the RR with their own material and impersonate the user.<br><br>
|
||||
Steps:
|
||||
<ol>
|
||||
<li>Sign the zone (Bind: <code>dnssec-policy default</code>; Knot: <code>dnssec-signing: on</code>; BIND/Knot-DNSSEC-policy or NSD+OpenDNSSEC, etc.).</li>
|
||||
<li>Publish the DS record at the parent via your registrar.</li>
|
||||
<li>Re-run this checker; the AD flag should light up.</li>
|
||||
</ol>`)
|
||||
|
||||
pick(CodeOwnerHashMismatch,
|
||||
"Fix the record's owner-name hash",
|
||||
`The record is published at a name whose first label does not equal <code>hex(sha256(localpart))[:56]</code> (28 bytes). Email agents will never find it because they compute the hash from the recipient address.<br><br>
|
||||
Compute the correct name:<br>
|
||||
<pre>printf '%s' "<em>local-part</em>" | openssl dgst -sha256 | cut -c 1-56 | tr -d '\n' ; echo ".<em>_openpgpkey</em>.<em>domain.tld</em>"</pre>
|
||||
Then republish the record at that owner name.`)
|
||||
|
||||
pick(CodePGPExpired,
|
||||
"Renew the expired OpenPGP key",
|
||||
`The primary key's self-signature expired, so clients will refuse to encrypt to it.<br>
|
||||
<pre>gpg --edit-key <fingerprint>
|
||||
gpg> expire
|
||||
... set a new expiration ...
|
||||
gpg> save
|
||||
gpg --export <fingerprint> | base64</pre>
|
||||
Paste the resulting base64 back into the OPENPGPKEY record.`)
|
||||
|
||||
pick(CodePGPRevoked,
|
||||
"Publish a fresh, non-revoked key",
|
||||
`The record carries a revoked primary key; clients will stop encrypting mail to this address as soon as they process the revocation.<br><br>
|
||||
Either generate a new key pair and publish it here, or remove the OPENPGPKEY record so senders fall back to regular email (unencrypted).`)
|
||||
|
||||
pick(CodePGPNoEncryption,
|
||||
"Add an encryption subkey",
|
||||
`Every non-revoked key in the record is marked sign-only. Mail clients will refuse to encrypt to this record.<br>
|
||||
<pre>gpg --edit-key <fingerprint>
|
||||
gpg> addkey
|
||||
... choose "RSA (encrypt only)" or "ECC (encrypt only)" ...
|
||||
gpg> save</pre>
|
||||
Re-export and republish.`)
|
||||
|
||||
pick(CodePGPWeakKeySize,
|
||||
"Rotate away from weak RSA keys",
|
||||
`RSA below 2048 bits is considered broken. Generate a modern key and republish:<br>
|
||||
<pre>gpg --full-generate-key
|
||||
# choose 1 (RSA+RSA) with 3072/4096 bits,
|
||||
# or 9 (ECC+ECC) for Curve25519.</pre>`)
|
||||
|
||||
pick(CodeSMIMEACertExpired,
|
||||
"Renew the S/MIME certificate",
|
||||
`The certificate expired. Issue a fresh one and update the SMIMEA record:<br>
|
||||
<pre>openssl req -new -key user.key -subj "/emailAddress=user@example.org" -out user.csr
|
||||
... obtain a signed cert from your S/MIME CA ...
|
||||
openssl x509 -in user.crt -outform DER | xxd -p -c256 > smimea.hex</pre>
|
||||
Splice the hex payload into the SMIMEA RDATA.`)
|
||||
|
||||
pick(CodeSMIMEANoEmailProtection,
|
||||
"Add the emailProtection EKU",
|
||||
`Conforming S/MIME agents (RFC 8550/8551) only accept certificates whose Extended Key Usage advertises email protection (OID 1.3.6.1.5.5.7.3.4).<br><br>
|
||||
In your <code>openssl.cnf</code>:<br>
|
||||
<pre>[usr_cert]
|
||||
extendedKeyUsage = emailProtection
|
||||
keyUsage = digitalSignature, keyEncipherment</pre>
|
||||
Re-issue the certificate, then update the SMIMEA record.`)
|
||||
|
||||
pick(CodeSMIMEAWeakSignatureAlg,
|
||||
"Re-issue with a strong signature algorithm",
|
||||
`MD5 and SHA-1 based signatures are collision-vulnerable and will be rejected by modern mail agents.<br><br>
|
||||
Use at least SHA-256 when issuing:<br>
|
||||
<pre>openssl x509 -req -sha256 -in user.csr -CA ca.pem -CAkey ca.key -out user.crt</pre>`)
|
||||
|
||||
pick(CodeSMIMEABadUsage,
|
||||
"Pick a valid SMIMEA usage",
|
||||
`SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA) or 3 (DANE-EE). For self-hosted end-entity certificates, <strong>3 (DANE-EE)</strong> is the right choice: it tells verifiers the record carries the exact certificate to trust and no chain validation is required.`)
|
||||
|
||||
pick(CodeSMIMEAHashOnly,
|
||||
"Consider publishing the full certificate",
|
||||
`Matching types 1 (SHA-256) and 2 (SHA-512) only transport a digest. Consumers cannot extract the certificate from DNS and must obtain it through a side channel. Matching type 0 (Full) avoids that round trip and is the most interoperable option.`)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func severityRank(s Severity) int {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return 3
|
||||
case SeverityWarn:
|
||||
return 2
|
||||
case SeverityInfo:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func smimeaUsageName(u uint8) string {
|
||||
switch u {
|
||||
case 0:
|
||||
return "0 PKIX-TA"
|
||||
case 1:
|
||||
return "1 PKIX-EE"
|
||||
case 2:
|
||||
return "2 DANE-TA"
|
||||
case 3:
|
||||
return "3 DANE-EE"
|
||||
}
|
||||
return fmt.Sprintf("%d (unknown)", u)
|
||||
}
|
||||
func smimeaSelectorName(s uint8) string {
|
||||
switch s {
|
||||
case 0:
|
||||
return "0 Cert"
|
||||
case 1:
|
||||
return "1 SPKI"
|
||||
}
|
||||
return fmt.Sprintf("%d (unknown)", s)
|
||||
}
|
||||
func smimeaMatchingTypeName(m uint8) string {
|
||||
switch m {
|
||||
case 0:
|
||||
return "0 Full"
|
||||
case 1:
|
||||
return "1 SHA-256"
|
||||
case 2:
|
||||
return "2 SHA-512"
|
||||
}
|
||||
return fmt.Sprintf("%d (unknown)", m)
|
||||
}
|
||||
|
||||
func kindRRType(k string) string {
|
||||
if k == KindSMIMEA {
|
||||
return "SMIMEA"
|
||||
}
|
||||
return "OPENPGPKEY"
|
||||
}
|
||||
func kindPrefix(k string) string {
|
||||
if k == KindSMIMEA {
|
||||
return "_smimecert"
|
||||
}
|
||||
return "_openpgpkey"
|
||||
}
|
||||
|
||||
func subkeyCaps(sk SubkeyInfo) string {
|
||||
var caps []string
|
||||
if sk.CanSign {
|
||||
caps = append(caps, "sign")
|
||||
}
|
||||
if sk.CanEncrypt {
|
||||
caps = append(caps, "encrypt")
|
||||
}
|
||||
if sk.CanAuth {
|
||||
caps = append(caps, "auth")
|
||||
}
|
||||
if len(caps) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(caps, ", ")
|
||||
}
|
||||
|
||||
func fmtTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
func fmtTimeOrNever(t time.Time) string {
|
||||
s := fmtTime(t)
|
||||
if s == "" {
|
||||
return "never"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func formatFingerprint(fp string) string {
|
||||
if fp == "" {
|
||||
return ""
|
||||
}
|
||||
fp = strings.ToUpper(fp)
|
||||
var b strings.Builder
|
||||
for i, r := range fp {
|
||||
if i > 0 && i%4 == 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var reportTemplate = template.Must(template.New("openpgpkey").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OPENPGPKEY / SMIMEA report</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px; line-height: 1.5; color: #1f2937; background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
pre {
|
||||
font-family: ui-monospace, monospace; font-size: .82em;
|
||||
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px;
|
||||
padding: .55rem .7rem; overflow-x: auto; margin: .35rem 0 0;
|
||||
}
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
|
||||
|
||||
.hd {
|
||||
background: #fff; border-radius: 10px;
|
||||
padding: 1rem 1.25rem; margin-bottom: .75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.hd h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em; border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.neutral { background: #e5e7eb; color: #374151; }
|
||||
.info { background: #dbeafe; color: #1e40af; }
|
||||
.sub { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
.sub code { color: #111827; }
|
||||
|
||||
.section {
|
||||
background: #fff; border-radius: 8px;
|
||||
padding: .85rem 1rem; margin-bottom: .6rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.reme {
|
||||
background: #fff7ed; border: 1px solid #fdba74; border-left: 4px solid #f97316;
|
||||
border-radius: 8px; padding: .75rem 1rem; margin-bottom: .6rem;
|
||||
}
|
||||
.reme h2 { color: #9a3412; }
|
||||
.reme-item { padding: .55rem 0; border-top: 1px dashed #fdba74; }
|
||||
.reme-item:first-of-type { border-top: none; padding-top: .25rem; }
|
||||
.reme-item h3 { color: #9a3412; margin-bottom: .25rem; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
td.sev-crit { color: #991b1b; font-weight: 600; }
|
||||
td.sev-warn { color: #92400e; font-weight: 600; }
|
||||
td.sev-info { color: #1e40af; font-weight: 600; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; column-gap: .8rem; row-gap: .15rem; font-size: .85rem; }
|
||||
.kv dt { color: #6b7280; }
|
||||
.kv dd { margin: 0; }
|
||||
|
||||
.pill {
|
||||
display: inline-block; padding: .1em .55em; border-radius: 9999px;
|
||||
font-size: .75rem; font-weight: 600; margin-right: .25rem; margin-bottom: .15rem;
|
||||
}
|
||||
.pill-on { background: #d1fae5; color: #065f46; }
|
||||
.pill-off { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.mono { font-family: ui-monospace, monospace; word-break: break-all; }
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
|
||||
.findings-empty { color: #065f46; padding: .4rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>
|
||||
{{if eq .Kind "openpgpkey"}}OPENPGPKEY record{{else}}SMIMEA record{{end}}
|
||||
<span class="badge {{.Badge}}">{{.Headline}}</span>
|
||||
</h1>
|
||||
<div class="sub">
|
||||
Queried: <code>{{.QueriedOwner}}</code>
|
||||
{{if and .ExpectedOwner (ne .ExpectedOwner .QueriedOwner)}} · expected <code>{{.ExpectedOwner}}</code>{{end}}
|
||||
{{if .Resolver}} · via <code>{{.Resolver}}</code>{{end}}
|
||||
{{if eq .DNSSEC "secure"}} · <span class="badge ok">DNSSEC ✓</span>
|
||||
{{else if eq .DNSSEC "insecure"}} · <span class="badge fail">DNSSEC ✗</span>
|
||||
{{else}} · <span class="badge neutral">DNSSEC ?</span>{{end}}
|
||||
{{if .Username}} · user <code>{{.Username}}</code>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Remediations}}
|
||||
<div class="reme">
|
||||
<h2>Most common issues (fix these first)</h2>
|
||||
{{range .Remediations}}
|
||||
<div class="reme-item">
|
||||
<h3>{{.Title}}</h3>
|
||||
<div>{{.Body}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{with .OpenPGP}}
|
||||
<div class="section">
|
||||
<h2>OpenPGP key</h2>
|
||||
<dl class="kv">
|
||||
<dt>Fingerprint</dt><dd class="mono">{{.Fingerprint}}</dd>
|
||||
<dt>Key ID</dt><dd class="mono">{{.KeyID}}</dd>
|
||||
<dt>Algorithm</dt><dd>{{.Algorithm}}{{if .Bits}} · {{.Bits}} bits{{end}}</dd>
|
||||
<dt>Created</dt><dd>{{.Created}}</dd>
|
||||
<dt>Expires</dt><dd>{{.Expires}}</dd>
|
||||
<dt>Revoked</dt><dd>{{if .Revoked}}<span class="pill pill-off">revoked</span>{{else}}<span class="pill pill-on">no</span>{{end}}</dd>
|
||||
<dt>Encrypt-capable</dt><dd>{{if .Encrypt}}<span class="pill pill-on">yes</span>{{else}}<span class="pill pill-off">no</span>{{end}}</dd>
|
||||
<dt>Record size</dt><dd>{{.RawSize}} bytes{{if gt .EntityCount 1}} · {{.EntityCount}} entities{{end}}</dd>
|
||||
<dt>Identities</dt><dd>{{range .UIDs}}<div class="mono">{{.}}</div>{{else}}<span class="note">(none)</span>{{end}}</dd>
|
||||
</dl>
|
||||
{{if .Subkeys}}
|
||||
<h3 style="margin-top:.8rem">Subkeys</h3>
|
||||
<table>
|
||||
<tr><th>Algorithm</th><th>Bits</th><th>Capabilities</th><th>Created</th><th>Expires</th><th>State</th></tr>
|
||||
{{range .Subkeys}}
|
||||
<tr>
|
||||
<td>{{.Algorithm}}</td>
|
||||
<td>{{if .Bits}}{{.Bits}}{{end}}</td>
|
||||
<td>{{.Capabilities}}</td>
|
||||
<td>{{.Created}}</td>
|
||||
<td>{{.Expires}}</td>
|
||||
<td>{{if .Revoked}}<span class="pill pill-off">revoked</span>{{else}}<span class="pill pill-on">ok</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{with .SMIMEA}}
|
||||
<div class="section">
|
||||
<h2>SMIMEA record</h2>
|
||||
<dl class="kv">
|
||||
<dt>Usage</dt><dd>{{.Usage}}</dd>
|
||||
<dt>Selector</dt><dd>{{.Selector}}</dd>
|
||||
<dt>Matching type</dt><dd>{{.MatchingType}}</dd>
|
||||
{{if .HashOnly}}
|
||||
<dt>Digest</dt><dd class="mono">{{.HashHex}}</dd>
|
||||
{{end}}
|
||||
{{if .Subject}}
|
||||
<dt>Subject</dt><dd class="mono">{{.Subject}}</dd>
|
||||
<dt>Issuer</dt><dd class="mono">{{.Issuer}}</dd>
|
||||
<dt>Serial</dt><dd class="mono">{{.Serial}}</dd>
|
||||
<dt>Valid from</dt><dd>{{.NotBefore}}</dd>
|
||||
<dt>Valid until</dt><dd>{{.NotAfter}}</dd>
|
||||
<dt>Signature</dt><dd>{{.SignatureAlgo}}</dd>
|
||||
<dt>Public key</dt><dd>{{.KeyAlgo}}{{if .Bits}} · {{.Bits}} bits{{end}}</dd>
|
||||
<dt>Emails</dt><dd>{{range .Emails}}<code>{{.}}</code> {{else}}<span class="note">(none)</span>{{end}}</dd>
|
||||
<dt>Flags</dt><dd>
|
||||
{{if .EmailProtection}}<span class="pill pill-on">emailProtection</span>{{else}}<span class="pill pill-off">no emailProtection EKU</span>{{end}}
|
||||
{{if .DigitalSignature}}<span class="pill pill-on">digitalSignature</span>{{end}}
|
||||
{{if .KeyEncipherment}}<span class="pill pill-on">keyEncipherment</span>{{end}}
|
||||
{{if .SelfSigned}}<span class="pill pill-off">self-signed</span>{{end}}
|
||||
{{if .IsCA}}<span class="pill pill-off">CA</span>{{end}}
|
||||
</dd>
|
||||
{{else if and .HashOnly .HashHex}}
|
||||
<dt>Certificate</dt><dd class="note">Digest only; see remediation below.</dd>
|
||||
{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section">
|
||||
<h2>Findings {{if .CritCount}}<span class="badge fail">{{.CritCount}} crit</span>{{end}}
|
||||
{{if .WarnCount}}<span class="badge warn">{{.WarnCount}} warn</span>{{end}}
|
||||
{{if .InfoCount}}<span class="badge info">{{.InfoCount}} info</span>{{end}}</h2>
|
||||
{{if .Findings}}
|
||||
<table>
|
||||
<tr><th>Severity</th><th>Code</th><th>Message</th><th>Fix</th></tr>
|
||||
{{range .Findings}}
|
||||
<tr>
|
||||
<td class="sev-{{.Severity}}">{{.Severity}}</td>
|
||||
<td><code>{{.Code}}</code></td>
|
||||
<td>{{.Message}}</td>
|
||||
<td>{{.Fix}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="findings-empty">No issues detected.</p>
|
||||
{{end}}
|
||||
<p class="note" style="margin-top:.6rem">Collected at {{.CollectedAt}}</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
114
checker/rule.go
Normal file
114
checker/rule.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rule returns the single aggregation rule for this checker. It folds
|
||||
// every finding produced by Collect into a CheckState whose status is
|
||||
// the worst severity seen.
|
||||
func Rule() sdk.CheckRule {
|
||||
return &emailKeyRule{}
|
||||
}
|
||||
|
||||
type emailKeyRule struct{}
|
||||
|
||||
func (r *emailKeyRule) Name() string { return "openpgpkey_smimea_check" }
|
||||
|
||||
func (r *emailKeyRule) Description() string {
|
||||
return "Validates a DNS-published OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162), running DNSSEC, record-hash, parse, expiration, algorithm-strength, and S/MIME EKU checks."
|
||||
}
|
||||
|
||||
func (r *emailKeyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
var data EmailKeyData
|
||||
if err := obs.Get(ctx, ObservationKey, &data); err != nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("Failed to read observation %q: %v", ObservationKey, err),
|
||||
Code: "openpgpkey_observation_error",
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{evaluate(&data)}
|
||||
}
|
||||
|
||||
// evaluate folds findings into a CheckState. The status is the highest
|
||||
// severity observed: any Crit makes the whole result Crit, any Warn
|
||||
// makes it Warn, otherwise Info/OK.
|
||||
func evaluate(data *EmailKeyData) sdk.CheckState {
|
||||
var crit, warn, info int
|
||||
var firstCrit, firstWarn, firstInfo string
|
||||
for _, f := range data.Findings {
|
||||
switch f.Severity {
|
||||
case SeverityCrit:
|
||||
crit++
|
||||
if firstCrit == "" {
|
||||
firstCrit = f.Message
|
||||
}
|
||||
case SeverityWarn:
|
||||
warn++
|
||||
if firstWarn == "" {
|
||||
firstWarn = f.Message
|
||||
}
|
||||
case SeverityInfo:
|
||||
info++
|
||||
if firstInfo == "" {
|
||||
firstInfo = f.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status := sdk.StatusOK
|
||||
msg := summariseHealthy(data)
|
||||
code := "openpgpkey_ok"
|
||||
switch {
|
||||
case crit > 0:
|
||||
status = sdk.StatusCrit
|
||||
msg = firstCrit
|
||||
code = "openpgpkey_crit"
|
||||
case warn > 0:
|
||||
status = sdk.StatusWarn
|
||||
msg = firstWarn
|
||||
code = "openpgpkey_warn"
|
||||
case info > 0:
|
||||
status = sdk.StatusInfo
|
||||
msg = firstInfo
|
||||
code = "openpgpkey_info"
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"kind": data.Kind,
|
||||
"queried": data.QueriedOwner,
|
||||
"record_count": data.RecordCount,
|
||||
"findings": data.Findings,
|
||||
}
|
||||
if data.DNSSECSecure != nil {
|
||||
meta["dnssec_secure"] = *data.DNSSECSecure
|
||||
}
|
||||
|
||||
return sdk.CheckState{
|
||||
Status: status,
|
||||
Message: msg,
|
||||
Code: code,
|
||||
Subject: data.QueriedOwner,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
func summariseHealthy(data *EmailKeyData) string {
|
||||
switch data.Kind {
|
||||
case KindOpenPGPKey:
|
||||
if data.OpenPGP != nil && data.OpenPGP.Fingerprint != "" {
|
||||
return fmt.Sprintf("OPENPGPKEY %s published and valid (fingerprint %s)", data.QueriedOwner, data.OpenPGP.Fingerprint)
|
||||
}
|
||||
return fmt.Sprintf("OPENPGPKEY %s published and valid", data.QueriedOwner)
|
||||
case KindSMIMEA:
|
||||
if data.SMIMEA != nil && data.SMIMEA.Certificate != nil {
|
||||
return fmt.Sprintf("SMIMEA %s valid (subject %s)", data.QueriedOwner, data.SMIMEA.Certificate.Subject)
|
||||
}
|
||||
return fmt.Sprintf("SMIMEA %s published and valid", data.QueriedOwner)
|
||||
}
|
||||
return "Record validated"
|
||||
}
|
||||
239
checker/types.go
Normal file
239
checker/types.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for
|
||||
// happyDomain. It runs a comprehensive testsuite on the DNS-published
|
||||
// OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162) corresponding
|
||||
// to an abstract.OpenPGP or abstract.SMimeCert service, and turns the
|
||||
// results into structured findings + a remediation-oriented HTML report.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ObservationKey is the key this checker publishes. The payload is an
|
||||
// *EmailKeyData JSON document.
|
||||
const ObservationKey = "openpgpkey_smimea"
|
||||
|
||||
// Supported service types.
|
||||
const (
|
||||
ServiceOpenPGP = "abstract.OpenPGP"
|
||||
ServiceSMimeCert = "abstract.SMimeCert"
|
||||
KindOpenPGPKey = "openpgpkey"
|
||||
KindSMIMEA = "smimea"
|
||||
OpenPGPKeyPrefix = "_openpgpkey"
|
||||
SMIMEACertPrefix = "_smimecert"
|
||||
DANEOwnerHashSize = 28 // bytes of SHA-256 kept as the owner prefix
|
||||
)
|
||||
|
||||
// Severity classifies a finding emitted by the checker.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityInfo Severity = "info"
|
||||
SeverityWarn Severity = "warn"
|
||||
SeverityCrit Severity = "crit"
|
||||
)
|
||||
|
||||
// Finding codes surfaced by the checker. These strings are stable; the
|
||||
// UI keys remediation templates off them.
|
||||
const (
|
||||
// DNS-level.
|
||||
CodeDNSQueryFailed = "dns_query_failed"
|
||||
CodeDNSNoRecord = "dns_no_record"
|
||||
CodeDNSRecordMismatch = "dns_record_mismatch"
|
||||
CodeDNSNotSecure = "dnssec_not_validated"
|
||||
CodeOwnerHashMismatch = "owner_hash_mismatch"
|
||||
|
||||
// OpenPGP.
|
||||
CodePGPParseError = "pgp_parse_error"
|
||||
CodePGPNoEntity = "pgp_no_entity"
|
||||
CodePGPRevoked = "pgp_primary_revoked"
|
||||
CodePGPExpired = "pgp_primary_expired"
|
||||
CodePGPExpiringSoon = "pgp_primary_expiring_soon"
|
||||
CodePGPWeakAlgorithm = "pgp_weak_algorithm"
|
||||
CodePGPWeakKeySize = "pgp_weak_key_size"
|
||||
CodePGPNoEncryption = "pgp_no_encryption_subkey"
|
||||
CodePGPNoIdentity = "pgp_no_identity"
|
||||
CodePGPUIDMismatch = "pgp_uid_mismatch"
|
||||
CodePGPMultipleEntities = "pgp_multiple_entities"
|
||||
CodePGPRecordTooLarge = "pgp_record_too_large"
|
||||
|
||||
// SMIMEA.
|
||||
CodeSMIMEABadUsage = "smimea_bad_usage"
|
||||
CodeSMIMEABadSelector = "smimea_bad_selector"
|
||||
CodeSMIMEABadMatchType = "smimea_bad_match_type"
|
||||
CodeSMIMEACertParseError = "smimea_cert_parse_error"
|
||||
CodeSMIMEACertExpired = "smimea_cert_expired"
|
||||
CodeSMIMEACertExpiringSoon = "smimea_cert_expiring_soon"
|
||||
CodeSMIMEACertNotYetValid = "smimea_cert_not_yet_valid"
|
||||
CodeSMIMEANoEmailProtection = "smimea_no_email_protection_eku"
|
||||
CodeSMIMEAEmailMismatch = "smimea_email_mismatch"
|
||||
CodeSMIMEAWeakKeySize = "smimea_weak_key_size"
|
||||
CodeSMIMEAWeakSignatureAlg = "smimea_weak_signature_algorithm"
|
||||
CodeSMIMEANoKeyUsage = "smimea_missing_key_usage"
|
||||
CodeSMIMEAChainUntrusted = "smimea_chain_untrusted"
|
||||
CodeSMIMEASelfSigned = "smimea_self_signed"
|
||||
CodeSMIMEAHashOnly = "smimea_hash_only"
|
||||
)
|
||||
|
||||
// Finding describes a single observation produced while running the
|
||||
// testsuite.
|
||||
type Finding struct {
|
||||
Code string `json:"code"`
|
||||
Severity Severity `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
// Fix carries a short, user-facing hint describing how to address the
|
||||
// issue. The HTML report falls back on generic Fix text keyed by Code
|
||||
// when this field is empty.
|
||||
Fix string `json:"fix,omitempty"`
|
||||
}
|
||||
|
||||
// EmailKeyData is the observation payload written under ObservationKey.
|
||||
type EmailKeyData struct {
|
||||
// Kind is "openpgpkey" or "smimea".
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// Domain is the FQDN of the zone (origin) that publishes the record.
|
||||
Domain string `json:"domain"`
|
||||
|
||||
// Subdomain is the relative name below Domain where the service sits
|
||||
// (empty for the zone apex).
|
||||
Subdomain string `json:"subdomain,omitempty"`
|
||||
|
||||
// Username is the local part copied from the service. When empty,
|
||||
// the username-hash-prefix verification is skipped.
|
||||
Username string `json:"username,omitempty"`
|
||||
|
||||
// ExpectedOwner is the FQDN at which the record should be
|
||||
// published, per RFC 7929 / RFC 8162.
|
||||
ExpectedOwner string `json:"expected_owner,omitempty"`
|
||||
|
||||
// QueriedOwner is the FQDN actually queried (may differ from
|
||||
// ExpectedOwner if the service record already carries its own name).
|
||||
QueriedOwner string `json:"queried_owner,omitempty"`
|
||||
|
||||
// Resolver is the DNS server that answered the lookup.
|
||||
Resolver string `json:"resolver,omitempty"`
|
||||
|
||||
// DNSSECSecure is true when the validating resolver set the AD flag
|
||||
// on the answer. Nil means the lookup did not complete.
|
||||
DNSSECSecure *bool `json:"dnssec_secure,omitempty"`
|
||||
|
||||
// RecordCount is the number of records returned at QueriedOwner.
|
||||
RecordCount int `json:"record_count"`
|
||||
|
||||
// OpenPGP is populated for kind=openpgpkey.
|
||||
OpenPGP *OpenPGPInfo `json:"openpgp,omitempty"`
|
||||
|
||||
// SMIMEA is populated for kind=smimea.
|
||||
SMIMEA *SMIMEAInfo `json:"smimea,omitempty"`
|
||||
|
||||
Findings []Finding `json:"findings"`
|
||||
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// OpenPGPInfo summarises the OpenPGP key observed in the record.
|
||||
type OpenPGPInfo struct {
|
||||
// RawSize is the length in bytes of the transport key material.
|
||||
RawSize int `json:"raw_size"`
|
||||
|
||||
// PrimaryAlgorithm is the name of the primary key's algorithm,
|
||||
// e.g. "RSA", "Ed25519", "ECDSA-NIST-P-256".
|
||||
PrimaryAlgorithm string `json:"primary_algorithm,omitempty"`
|
||||
|
||||
// PrimaryBits is the key size in bits for the primary key (0 when
|
||||
// the algorithm is of fixed size, e.g. Ed25519).
|
||||
PrimaryBits int `json:"primary_bits,omitempty"`
|
||||
|
||||
// Fingerprint is the hex-encoded OpenPGP fingerprint.
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
|
||||
// KeyID is the short 64-bit key id, hex.
|
||||
KeyID string `json:"key_id,omitempty"`
|
||||
|
||||
// UIDs lists the User ID strings carried in the key.
|
||||
UIDs []string `json:"uids,omitempty"`
|
||||
|
||||
// CreatedAt is the primary key creation time.
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
|
||||
// ExpiresAt is the primary key expiration time (zero for "never").
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
|
||||
// Revoked is true when the primary key carries a revocation signature.
|
||||
Revoked bool `json:"revoked,omitempty"`
|
||||
|
||||
// Subkeys describes the subordinate keys.
|
||||
Subkeys []SubkeyInfo `json:"subkeys,omitempty"`
|
||||
|
||||
// EntityCount is the number of OpenPGP entities parsed from the
|
||||
// record. RFC 7929 recommends a single entity per record.
|
||||
EntityCount int `json:"entity_count"`
|
||||
|
||||
// HasEncryptionCapability is true when at least one non-revoked,
|
||||
// non-expired key in the entity advertises encryption usage flags.
|
||||
HasEncryptionCapability bool `json:"has_encryption_capability"`
|
||||
}
|
||||
|
||||
// SubkeyInfo summarises one OpenPGP subkey.
|
||||
type SubkeyInfo struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Bits int `json:"bits,omitempty"`
|
||||
CanSign bool `json:"can_sign,omitempty"`
|
||||
CanEncrypt bool `json:"can_encrypt,omitempty"`
|
||||
CanAuth bool `json:"can_auth,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
Revoked bool `json:"revoked,omitempty"`
|
||||
}
|
||||
|
||||
// SMIMEAInfo summarises the S/MIME record.
|
||||
type SMIMEAInfo struct {
|
||||
Usage uint8 `json:"usage"`
|
||||
Selector uint8 `json:"selector"`
|
||||
MatchingType uint8 `json:"matching_type"`
|
||||
|
||||
// Certificate is populated when the record carries a full X.509
|
||||
// certificate (selector 0, matching type 0). For selector 1 + type 0
|
||||
// only PublicKey is populated. For matching types 1/2, neither is
|
||||
// populated; only the digest is transported.
|
||||
Certificate *CertInfo `json:"certificate,omitempty"`
|
||||
PublicKey *PubKeyInfo `json:"public_key,omitempty"`
|
||||
|
||||
// HashHex, when set, is the hex digest embedded in the record.
|
||||
HashHex string `json:"hash_hex,omitempty"`
|
||||
}
|
||||
|
||||
// CertInfo summarises an X.509 certificate.
|
||||
type CertInfo struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
SerialHex string `json:"serial_hex,omitempty"`
|
||||
NotBefore time.Time `json:"not_before,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
SignatureAlgorithm string `json:"signature_algorithm,omitempty"`
|
||||
PublicKeyAlgorithm string `json:"public_key_algorithm,omitempty"`
|
||||
PublicKeyBits int `json:"public_key_bits,omitempty"`
|
||||
EmailAddresses []string `json:"email_addresses,omitempty"`
|
||||
DNSNames []string `json:"dns_names,omitempty"`
|
||||
HasEmailProtectionEKU bool `json:"has_email_protection_eku,omitempty"`
|
||||
HasDigitalSignature bool `json:"has_digital_signature,omitempty"`
|
||||
HasKeyEncipherment bool `json:"has_key_encipherment,omitempty"`
|
||||
IsSelfSigned bool `json:"is_self_signed,omitempty"`
|
||||
IsCA bool `json:"is_ca,omitempty"`
|
||||
}
|
||||
|
||||
// PubKeyInfo summarises an SPKI-only SMIMEA record.
|
||||
type PubKeyInfo struct {
|
||||
Algorithm string `json:"algorithm,omitempty"`
|
||||
Bits int `json:"bits,omitempty"`
|
||||
}
|
||||
|
||||
// serviceMessage is a minimal mirror of happyDomain's ServiceMessage JSON
|
||||
// envelope used to carry the auto-filled service.
|
||||
type serviceMessage struct {
|
||||
Type string `json:"_svctype"`
|
||||
Domain string `json:"_domain"`
|
||||
Service json.RawMessage `json:"Service"`
|
||||
}
|
||||
19
go.mod
Normal file
19
go.mod
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
module git.happydns.org/checker-email-keys
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.1.0
|
||||
github.com/ProtonMail/go-crypto v1.1.0-alpha.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cloudflare/circl v1.3.7 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
)
|
||||
22
go.sum
Normal file
22
go.sum
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y=
|
||||
git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
github.com/ProtonMail/go-crypto v1.1.0-alpha.0 h1:nHGfwXmFvJrSR9xu8qL7BkO4DqTHXE9N5vPhgY2I+j0=
|
||||
github.com/ProtonMail/go-crypto v1.1.0-alpha.0/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
|
||||
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
27
main.go
Normal file
27
main.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
emailkeys "git.happydns.org/checker-email-keys/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is the standalone binary's version. Override with:
|
||||
//
|
||||
// go build -ldflags "-X main.Version=1.2.3" .
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
emailkeys.Version = Version
|
||||
|
||||
server := sdk.NewServer(emailkeys.Provider())
|
||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
21
plugin/plugin.go
Normal file
21
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Command plugin is the happyDomain plugin entrypoint for the
|
||||
// OPENPGPKEY/SMIMEA checker. Built as a Go plugin and loaded at runtime
|
||||
// by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
emailkeys "git.happydns.org/checker-email-keys/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is the plugin's version, meant to be overridden by CI:
|
||||
//
|
||||
// go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-email-keys.so ./plugin
|
||||
var Version = "custom-build"
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain when loading
|
||||
// the .so file.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
emailkeys.Version = Version
|
||||
return emailkeys.Definition(), emailkeys.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue