Initial commit

This commit is contained in:
nemunaire 2026-04-23 18:30:01 +07:00
commit c1020c8be7
16 changed files with 118 additions and 209 deletions

4
.gitignore vendored
View file

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

View file

@ -6,9 +6,9 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-openpgpkey . RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-email-keys .
FROM scratch FROM scratch
COPY --from=builder /checker-openpgpkey /checker-openpgpkey COPY --from=builder /checker-email-keys /checker-email-keys
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/checker-openpgpkey"] ENTRYPOINT ["/checker-email-keys"]

View file

@ -1,4 +1,4 @@
CHECKER_NAME := checker-openpgpkey CHECKER_NAME := checker-email-keys
CHECKER_IMAGE := happydomain/$(CHECKER_NAME) CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build CHECKER_VERSION ?= custom-build

2
NOTICE
View file

@ -1,4 +1,4 @@
checker-openpgpkey checker-email-keys
Copyright (c) 2026 The happyDomain Authors Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE). This product is licensed under the MIT License (see LICENSE).

View file

@ -1,4 +1,4 @@
# checker-openpgpkey # checker-email-keys
DANE-Email posture checker for happyDomain. DANE-Email posture checker for happyDomain.
@ -66,28 +66,6 @@ rule engine can fold them into a single `CheckState`.
| `smimea_self_signed` | info | Self-signed certificate paired with PKIX-EE usage. | | `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. | | `smimea_hash_only` | info | Matching-type 1/2 only carries a digest; certificate can't be inspected. |
## Why a bespoke checker instead of a third-party testsuite?
There is no canonical "OPENPGPKEY / SMIMEA testsuite" in Go or as a
self-hostable online service:
- `ldns-dane` (NLnet Labs) validates DANE-TLSA and handles SMIMEA only
shallowly (it parses the record without deep certificate checks).
- `hokey` (Paul Wouters) queries OPENPGPKEY but does not validate the
key material.
- Online DANE validators (e.g. `dane.sys4.de`, `has-tls-rpt.com`) focus
on SMTP DANE-TLSA, not email-identity records.
The heavy lifting here is standard Go parsing:
- `github.com/ProtonMail/go-crypto/openpgp` (maintained fork of the
deprecated `golang.org/x/crypto/openpgp`) for OpenPGP packet parsing,
UIDs, subkeys, revocations, key-lifetime self-signatures.
- `crypto/x509` for SMIMEA certificate parsing, validity window, EKU,
key-usage, signature-algorithm and key-size checks.
- `github.com/miekg/dns` for the DNS+EDNS0+DO query and the `AD` flag
read-back used as the DNSSEC-validation signal.
## Options ## Options
| Id | Type | Default | Description | | Id | Type | Default | Description |
@ -107,22 +85,5 @@ Auto-filled by the host: `domain_name`, `subdomain`, `service`,
make plugin make plugin
# Standalone HTTP server # Standalone HTTP server
make && ./checker-openpgpkey -listen :8080 make && ./checker-email-keys -listen :8080
``` ```
## HTML report
The report renders as a self-contained HTML document intended for
embedding in an `<iframe>` (the same contract as the other happyDomain
checkers). It is organised as:
1. **Header**: status badge, queried owner name, resolver used, DNSSEC
flag.
2. **Most common issues (fix these first)**: remediation cards shown
*only* when a matching finding was emitted. Each card carries the
concrete shell commands / zone-file snippets the user needs.
3. **OpenPGP key / SMIMEA record**: structured details for the
parsed material (fingerprint, UIDs, subkeys, cert subject/issuer,
EKU/KU flags, …).
4. **Findings**: the full table of per-finding code / severity /
message / fix hints, sorted by severity.

View file

@ -1,9 +1,3 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
package checker package checker
import ( import (
@ -32,9 +26,9 @@ import (
// serviceBody is the common envelope for the two services. // serviceBody is the common envelope for the two services.
type serviceBody struct { type serviceBody struct {
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
OpenPGP *dns.OPENPGPKEY `json:"openpgpkey,omitempty"` OpenPGP *dns.OPENPGPKEY `json:"openpgpkey,omitempty"`
SMIMEA *dns.SMIMEA `json:"smimea,omitempty"` SMIMEA *dns.SMIMEA `json:"smimea,omitempty"`
} }
// Collect runs the full DANE-email testsuite and returns an *EmailKeyData // Collect runs the full DANE-email testsuite and returns an *EmailKeyData
@ -123,13 +117,9 @@ func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
data.RecordCount = len(ans.Records) data.RecordCount = len(ans.Records)
if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 { if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 {
sev := SeverityCrit
if ans.Rcode == dns.RcodeNameError {
sev = SeverityCrit
}
data.Findings = append(data.Findings, Finding{ data.Findings = append(data.Findings, Finding{
Code: CodeDNSNoRecord, Code: CodeDNSNoRecord,
Severity: sev, Severity: SeverityCrit,
Message: fmt.Sprintf("Authoritative DNS returned no %s record at %s.", dns.TypeToString[qtype], data.QueriedOwner), 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.", Fix: "Ensure the record is present in the zone and that the zone has been loaded by the authoritative servers.",
}) })

View file

@ -1,9 +1,3 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
package checker package checker
import ( import (
@ -18,9 +12,9 @@ var Version = "built-in"
// Option ids. // Option ids.
const ( const (
OptionResolver = "resolver" OptionResolver = "resolver"
OptionCertExpiryWarnDays = "certExpiryWarnDays" OptionCertExpiryWarnDays = "certExpiryWarnDays"
OptionRequireDNSSEC = "requireDNSSEC" OptionRequireDNSSEC = "requireDNSSEC"
OptionRequireEmailProtection = "requireEmailProtection" OptionRequireEmailProtection = "requireEmailProtection"
) )

View file

@ -1,9 +1,3 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
package checker package checker
import ( import (

View file

@ -1,9 +1,3 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
package checker package checker
import ( import (

View file

@ -1,9 +1,3 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
package checker package checker
import ( import (

View file

@ -1,9 +1,3 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
package checker package checker
import ( import (
@ -47,16 +41,16 @@ type subkeyRow struct {
// reportData is the template context. // reportData is the template context.
type reportData struct { type reportData struct {
Kind string Kind string
Headline string Headline string
Badge string // "ok" / "warn" / "fail" / "neutral" Badge string // "ok" / "warn" / "fail" / "neutral"
QueriedOwner string QueriedOwner string
ExpectedOwner string ExpectedOwner string
Resolver string Resolver string
DNSSEC string // "secure" / "insecure" / "unknown" DNSSEC string // "secure" / "insecure" / "unknown"
RecordCount int RecordCount int
Username string Username string
CollectedAt string CollectedAt string
OpenPGP *openPGPView OpenPGP *openPGPView
SMIMEA *smimeaView SMIMEA *smimeaView
@ -69,41 +63,41 @@ type reportData struct {
} }
type openPGPView struct { type openPGPView struct {
Fingerprint string Fingerprint string
KeyID string KeyID string
Algorithm string Algorithm string
Bits int Bits int
UIDs []string UIDs []string
Created string Created string
Expires string Expires string
Revoked bool Revoked bool
Encrypt bool Encrypt bool
Subkeys []subkeyRow Subkeys []subkeyRow
RawSize int RawSize int
EntityCount int EntityCount int
} }
type smimeaView struct { type smimeaView struct {
Usage string Usage string
Selector string Selector string
MatchingType string MatchingType string
HashOnly bool HashOnly bool
HashHex string HashHex string
Subject string Subject string
Issuer string Issuer string
Serial string Serial string
NotBefore string NotBefore string
NotAfter string NotAfter string
SignatureAlgo string SignatureAlgo string
KeyAlgo string KeyAlgo string
Bits int Bits int
Emails []string Emails []string
DNSNames []string DNSNames []string
EmailProtection bool EmailProtection bool
DigitalSignature bool DigitalSignature bool
KeyEncipherment bool KeyEncipherment bool
SelfSigned bool SelfSigned bool
IsCA bool IsCA bool
} }
// GetHTMLReport implements sdk.CheckerHTMLReporter. // GetHTMLReport implements sdk.CheckerHTMLReporter.

View file

@ -1,9 +1,3 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
package checker package checker
import ( import (

View file

@ -1,9 +1,3 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// Licensed under the MIT License (see LICENSE).
// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for // Package checker implements the OPENPGPKEY/SMIMEA DANE checker for
// happyDomain. It runs a comprehensive testsuite on the DNS-published // happyDomain. It runs a comprehensive testsuite on the DNS-published
// OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162) corresponding // OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162) corresponding
@ -44,42 +38,42 @@ const (
// UI keys remediation templates off them. // UI keys remediation templates off them.
const ( const (
// DNS-level. // DNS-level.
CodeDNSQueryFailed = "dns_query_failed" CodeDNSQueryFailed = "dns_query_failed"
CodeDNSNoRecord = "dns_no_record" CodeDNSNoRecord = "dns_no_record"
CodeDNSRecordMismatch = "dns_record_mismatch" CodeDNSRecordMismatch = "dns_record_mismatch"
CodeDNSNotSecure = "dnssec_not_validated" CodeDNSNotSecure = "dnssec_not_validated"
CodeOwnerHashMismatch = "owner_hash_mismatch" CodeOwnerHashMismatch = "owner_hash_mismatch"
// OpenPGP. // OpenPGP.
CodePGPParseError = "pgp_parse_error" CodePGPParseError = "pgp_parse_error"
CodePGPNoEntity = "pgp_no_entity" CodePGPNoEntity = "pgp_no_entity"
CodePGPRevoked = "pgp_primary_revoked" CodePGPRevoked = "pgp_primary_revoked"
CodePGPExpired = "pgp_primary_expired" CodePGPExpired = "pgp_primary_expired"
CodePGPExpiringSoon = "pgp_primary_expiring_soon" CodePGPExpiringSoon = "pgp_primary_expiring_soon"
CodePGPWeakAlgorithm = "pgp_weak_algorithm" CodePGPWeakAlgorithm = "pgp_weak_algorithm"
CodePGPWeakKeySize = "pgp_weak_key_size" CodePGPWeakKeySize = "pgp_weak_key_size"
CodePGPNoEncryption = "pgp_no_encryption_subkey" CodePGPNoEncryption = "pgp_no_encryption_subkey"
CodePGPNoIdentity = "pgp_no_identity" CodePGPNoIdentity = "pgp_no_identity"
CodePGPUIDMismatch = "pgp_uid_mismatch" CodePGPUIDMismatch = "pgp_uid_mismatch"
CodePGPMultipleEntities = "pgp_multiple_entities" CodePGPMultipleEntities = "pgp_multiple_entities"
CodePGPRecordTooLarge = "pgp_record_too_large" CodePGPRecordTooLarge = "pgp_record_too_large"
// SMIMEA. // SMIMEA.
CodeSMIMEABadUsage = "smimea_bad_usage" CodeSMIMEABadUsage = "smimea_bad_usage"
CodeSMIMEABadSelector = "smimea_bad_selector" CodeSMIMEABadSelector = "smimea_bad_selector"
CodeSMIMEABadMatchType = "smimea_bad_match_type" CodeSMIMEABadMatchType = "smimea_bad_match_type"
CodeSMIMEACertParseError = "smimea_cert_parse_error" CodeSMIMEACertParseError = "smimea_cert_parse_error"
CodeSMIMEACertExpired = "smimea_cert_expired" CodeSMIMEACertExpired = "smimea_cert_expired"
CodeSMIMEACertExpiringSoon = "smimea_cert_expiring_soon" CodeSMIMEACertExpiringSoon = "smimea_cert_expiring_soon"
CodeSMIMEACertNotYetValid = "smimea_cert_not_yet_valid" CodeSMIMEACertNotYetValid = "smimea_cert_not_yet_valid"
CodeSMIMEANoEmailProtection = "smimea_no_email_protection_eku" CodeSMIMEANoEmailProtection = "smimea_no_email_protection_eku"
CodeSMIMEAEmailMismatch = "smimea_email_mismatch" CodeSMIMEAEmailMismatch = "smimea_email_mismatch"
CodeSMIMEAWeakKeySize = "smimea_weak_key_size" CodeSMIMEAWeakKeySize = "smimea_weak_key_size"
CodeSMIMEAWeakSignatureAlg = "smimea_weak_signature_algorithm" CodeSMIMEAWeakSignatureAlg = "smimea_weak_signature_algorithm"
CodeSMIMEANoKeyUsage = "smimea_missing_key_usage" CodeSMIMEANoKeyUsage = "smimea_missing_key_usage"
CodeSMIMEAChainUntrusted = "smimea_chain_untrusted" CodeSMIMEAChainUntrusted = "smimea_chain_untrusted"
CodeSMIMEASelfSigned = "smimea_self_signed" CodeSMIMEASelfSigned = "smimea_self_signed"
CodeSMIMEAHashOnly = "smimea_hash_only" CodeSMIMEAHashOnly = "smimea_hash_only"
) )
// Finding describes a single observation produced while running the // Finding describes a single observation produced while running the
@ -184,14 +178,14 @@ type OpenPGPInfo struct {
// SubkeyInfo summarises one OpenPGP subkey. // SubkeyInfo summarises one OpenPGP subkey.
type SubkeyInfo struct { type SubkeyInfo struct {
Algorithm string `json:"algorithm"` Algorithm string `json:"algorithm"`
Bits int `json:"bits,omitempty"` Bits int `json:"bits,omitempty"`
CanSign bool `json:"can_sign,omitempty"` CanSign bool `json:"can_sign,omitempty"`
CanEncrypt bool `json:"can_encrypt,omitempty"` CanEncrypt bool `json:"can_encrypt,omitempty"`
CanAuth bool `json:"can_auth,omitempty"` CanAuth bool `json:"can_auth,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"`
Revoked bool `json:"revoked,omitempty"` Revoked bool `json:"revoked,omitempty"`
} }
// SMIMEAInfo summarises the S/MIME record. // SMIMEAInfo summarises the S/MIME record.
@ -204,7 +198,7 @@ type SMIMEAInfo struct {
// certificate (selector 0, matching type 0). For selector 1 + type 0 // certificate (selector 0, matching type 0). For selector 1 + type 0
// only PublicKey is populated. For matching types 1/2, neither is // only PublicKey is populated. For matching types 1/2, neither is
// populated; only the digest is transported. // populated; only the digest is transported.
Certificate *CertInfo `json:"certificate,omitempty"` Certificate *CertInfo `json:"certificate,omitempty"`
PublicKey *PubKeyInfo `json:"public_key,omitempty"` PublicKey *PubKeyInfo `json:"public_key,omitempty"`
// HashHex, when set, is the hex digest embedded in the record. // HashHex, when set, is the hex digest embedded in the record.
@ -213,21 +207,21 @@ type SMIMEAInfo struct {
// CertInfo summarises an X.509 certificate. // CertInfo summarises an X.509 certificate.
type CertInfo struct { type CertInfo struct {
Subject string `json:"subject,omitempty"` Subject string `json:"subject,omitempty"`
Issuer string `json:"issuer,omitempty"` Issuer string `json:"issuer,omitempty"`
SerialHex string `json:"serial_hex,omitempty"` SerialHex string `json:"serial_hex,omitempty"`
NotBefore time.Time `json:"not_before,omitempty"` NotBefore time.Time `json:"not_before,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"` NotAfter time.Time `json:"not_after,omitempty"`
SignatureAlgorithm string `json:"signature_algorithm,omitempty"` SignatureAlgorithm string `json:"signature_algorithm,omitempty"`
PublicKeyAlgorithm string `json:"public_key_algorithm,omitempty"` PublicKeyAlgorithm string `json:"public_key_algorithm,omitempty"`
PublicKeyBits int `json:"public_key_bits,omitempty"` PublicKeyBits int `json:"public_key_bits,omitempty"`
EmailAddresses []string `json:"email_addresses,omitempty"` EmailAddresses []string `json:"email_addresses,omitempty"`
DNSNames []string `json:"dns_names,omitempty"` DNSNames []string `json:"dns_names,omitempty"`
HasEmailProtectionEKU bool `json:"has_email_protection_eku,omitempty"` HasEmailProtectionEKU bool `json:"has_email_protection_eku,omitempty"`
HasDigitalSignature bool `json:"has_digital_signature,omitempty"` HasDigitalSignature bool `json:"has_digital_signature,omitempty"`
HasKeyEncipherment bool `json:"has_key_encipherment,omitempty"` HasKeyEncipherment bool `json:"has_key_encipherment,omitempty"`
IsSelfSigned bool `json:"is_self_signed,omitempty"` IsSelfSigned bool `json:"is_self_signed,omitempty"`
IsCA bool `json:"is_ca,omitempty"` IsCA bool `json:"is_ca,omitempty"`
} }
// PubKeyInfo summarises an SPKI-only SMIMEA record. // PubKeyInfo summarises an SPKI-only SMIMEA record.

2
go.mod
View file

@ -1,4 +1,4 @@
module git.happydns.org/checker-openpgpkey module git.happydns.org/checker-email-keys
go 1.25.0 go 1.25.0

10
main.go
View file

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

View file

@ -4,18 +4,18 @@
package main package main
import ( import (
openpgpkey "git.happydns.org/checker-openpgpkey/checker" emailkeys "git.happydns.org/checker-email-keys/checker"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Version is the plugin's version, meant to be overridden by CI: // 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-openpgpkey.so ./plugin // go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-email-keys.so ./plugin
var Version = "custom-build" var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading // NewCheckerPlugin is the symbol resolved by happyDomain when loading
// the .so file. // the .so file.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
openpgpkey.Version = Version emailkeys.Version = Version
return openpgpkey.Definition(), openpgpkey.Provider(), nil return emailkeys.Definition(), emailkeys.Provider(), nil
} }