package checker import ( "encoding/json" "fmt" "html/template" "sort" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) // remediation is a single actionable hint shown in the report's // "most common issues, fix these first" banner. Bodies are rendered // with template.HTML so each remediation can ship its own markup // (pre-formatted code snippets, lists, links). type remediation struct { Title string Body template.HTML } // findingRow models a single row in the full findings table. type findingRow struct { Code string Severity string Message string Fix string } // subkeyRow mirrors SubkeyInfo for the template, with pre-formatted // times and a Capabilities string. type subkeyRow struct { Algorithm string Bits int Capabilities string Created string Expires string Revoked bool } // reportData is the template context. type reportData struct { Kind string Headline string Badge string // "ok" / "warn" / "fail" / "neutral" QueriedOwner string ExpectedOwner string Resolver string DNSSEC string // "secure" / "insecure" / "unknown" RecordCount int Username string CollectedAt string OpenPGP *openPGPView SMIMEA *smimeaView Remediations []remediation Findings []findingRow HasStates bool // true when rule states were threaded; gates the Findings section CritCount int WarnCount int InfoCount int } type openPGPView struct { Fingerprint string KeyID string Algorithm string Bits int UIDs []string Created string Expires string Revoked bool Encrypt bool Subkeys []subkeyRow RawSize int EntityCount int } type smimeaView struct { Usage string Selector string MatchingType string HashOnly bool HashHex string Subject string Issuer string Serial string NotBefore string NotAfter string SignatureAlgo string KeyAlgo string Bits int Emails []string DNSNames []string EmailProtection bool DigitalSignature bool KeyEncipherment bool SelfSigned bool IsCA bool } // GetHTMLReport implements sdk.CheckerHTMLReporter. func (p *emailKeyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data EmailKeyData if err := json.Unmarshal(ctx.Data(), &data); err != nil { return "", fmt.Errorf("unmarshal report data: %w", err) } rd := buildReportData(&data, ctx.States()) var buf strings.Builder if err := reportTemplate.Execute(&buf, rd); err != nil { return "", fmt.Errorf("render report: %w", err) } return buf.String(), nil } func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData { rd := reportData{ Kind: d.Kind, QueriedOwner: d.QueriedOwner, ExpectedOwner: d.ExpectedOwner, Resolver: d.Resolver, RecordCount: d.RecordCount, Username: d.Username, CollectedAt: d.CollectedAt.UTC().Format(time.RFC3339), } switch { case d.DNSSECSecure == nil: rd.DNSSEC = "unknown" case *d.DNSSECSecure: rd.DNSSEC = "secure" default: rd.DNSSEC = "insecure" } if d.Kind == KindOpenPGPKey && d.OpenPGP != nil { rd.OpenPGP = buildOpenPGPView(d.OpenPGP) } if d.Kind == KindSMIMEA && d.SMIMEA != nil { rd.SMIMEA = buildSMIMEAView(d.SMIMEA) } // No rule states threaded through: data-only view. if len(states) == 0 { rd.Badge = "neutral" rd.Headline = "Record details" return rd } rd.HasStates = true // Pick the states we want on screen: drop bare StatusOK, and drop // StatusInfo with no message (non-applicable rules). Keep anything // else. kept := make([]sdk.CheckState, 0, len(states)) for _, s := range states { if s.Status == sdk.StatusOK { continue } if s.Status == sdk.StatusInfo && strings.TrimSpace(s.Message) == "" { continue } kept = append(kept, s) } // Sort by severity (crit first). sort.SliceStable(kept, func(i, j int) bool { return statusRank(kept[i].Status) > statusRank(kept[j].Status) }) for _, s := range kept { rd.Findings = append(rd.Findings, findingRow{ Code: s.Code, Severity: severityLabel(s.Status), Message: s.Message, Fix: stateHint(s), }) switch s.Status { case sdk.StatusCrit, sdk.StatusError: rd.CritCount++ case sdk.StatusWarn: rd.WarnCount++ case sdk.StatusInfo: rd.InfoCount++ } } switch { case rd.CritCount > 0: rd.Badge = "fail" rd.Headline = fmt.Sprintf("%d critical issue(s) found", rd.CritCount) case rd.WarnCount > 0: rd.Badge = "warn" rd.Headline = fmt.Sprintf("%d warning(s)", rd.WarnCount) case rd.InfoCount > 0: rd.Badge = "neutral" rd.Headline = "Informational findings" default: rd.Badge = "ok" rd.Headline = "All checks passed" } rd.Remediations = buildRemediations(d, kept) return rd } func stateHint(s sdk.CheckState) string { if s.Meta == nil { return "" } if v, ok := s.Meta["hint"].(string); ok { return v } return "" } func severityLabel(st sdk.Status) string { switch st { case sdk.StatusCrit, sdk.StatusError: return "crit" case sdk.StatusWarn: return "warn" case sdk.StatusInfo: return "info" } return "info" } func statusRank(st sdk.Status) int { switch st { case sdk.StatusCrit, sdk.StatusError: return 3 case sdk.StatusWarn: return 2 case sdk.StatusInfo: return 1 } return 0 } func buildOpenPGPView(o *OpenPGPInfo) *openPGPView { v := &openPGPView{ Fingerprint: formatFingerprint(o.Fingerprint), KeyID: o.KeyID, Algorithm: o.PrimaryAlgorithm, Bits: o.PrimaryBits, UIDs: append([]string(nil), o.UIDs...), Created: fmtTime(o.CreatedAt), Expires: fmtTime(o.ExpiresAt), Revoked: o.Revoked, Encrypt: o.HasEncryptionCapability, RawSize: o.RawSize, EntityCount: o.EntityCount, } if v.Expires == "" { v.Expires = "never" } sort.Strings(v.UIDs) for _, sk := range o.Subkeys { caps := subkeyCaps(sk) v.Subkeys = append(v.Subkeys, subkeyRow{ Algorithm: sk.Algorithm, Bits: sk.Bits, Capabilities: caps, Created: fmtTime(sk.CreatedAt), Expires: fmtTimeOrNever(sk.ExpiresAt), Revoked: sk.Revoked, }) } return v } func buildSMIMEAView(s *SMIMEAInfo) *smimeaView { v := &smimeaView{ Usage: smimeaUsageName(s.Usage), Selector: smimeaSelectorName(s.Selector), MatchingType: smimeaMatchingTypeName(s.MatchingType), HashOnly: s.MatchingType != 0, HashHex: s.HashHex, } if s.Certificate != nil { c := s.Certificate v.Subject = c.Subject v.Issuer = c.Issuer v.Serial = c.SerialHex v.NotBefore = fmtTime(c.NotBefore) v.NotAfter = fmtTime(c.NotAfter) v.SignatureAlgo = c.SignatureAlgorithm v.KeyAlgo = c.PublicKeyAlgorithm v.Bits = c.PublicKeyBits v.Emails = append([]string(nil), c.EmailAddresses...) v.DNSNames = append([]string(nil), c.DNSNames...) v.EmailProtection = c.HasEmailProtectionEKU v.DigitalSignature = c.HasDigitalSignature v.KeyEncipherment = c.HasKeyEncipherment v.SelfSigned = c.IsSelfSigned v.IsCA = c.IsCA } if s.PublicKey != nil && v.KeyAlgo == "" { v.KeyAlgo = s.PublicKey.Algorithm v.Bits = s.PublicKey.Bits } return v } // buildRemediations surfaces a focused, user-actionable card for each // of the most common failure scenarios present in `states`. Only rules // with a matching state produce a remediation; a clean run shows none. func buildRemediations(d *EmailKeyData, states []sdk.CheckState) []remediation { var out []remediation byCode := map[string]bool{} for _, s := range states { byCode[s.Code] = true } pick := func(code, title, body string) { if !byCode[code] { return } out = append(out, remediation{Title: title, Body: template.HTML(body)}) } pick(RuleDNSNoRecord, "Publish the record in DNS", fmt.Sprintf(`No %s record resolves at %s. Publish it in the zone and reload the authoritative servers.

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

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

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

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

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

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

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

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

Most common issues (fix these first)

{{range .Remediations}}

{{.Title}}

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

OpenPGP key

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

Subkeys

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

SMIMEA record

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

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

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

No issues detected.

{{end}} {{end}}

Collected at {{.CollectedAt}}

`))