719 lines
22 KiB
Go
719 lines
22 KiB
Go
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 <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(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.<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(RuleOwnerHashMismatch,
|
|
"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(RulePGPPrimaryExpired,
|
|
"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(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.<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(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.<br>
|
|
<pre>gpg --edit-key <fingerprint>
|
|
gpg> addkey
|
|
... choose "RSA (encrypt only)" or "ECC (encrypt only)" ...
|
|
gpg> save</pre>
|
|
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:<br>
|
|
<pre>gpg --full-generate-key
|
|
# choose 1 (RSA+RSA) with 3072/4096 bits,
|
|
# or 9 (ECC+ECC) for Curve25519.</pre>`)
|
|
|
|
pick(RuleSMIMEACertExpired,
|
|
"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(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).<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(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.<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(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, <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(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(`<!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">
|
|
{{if .HasStates}}
|
|
<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}}
|
|
{{end}}
|
|
<p class="note" style="margin-top:.6rem">Collected at {{.CollectedAt}}</p>
|
|
</div>
|
|
|
|
</body>
|
|
</html>`))
|