checker-email-keys/checker/rules_pgp.go

213 lines
7.3 KiB
Go

package checker
import (
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// OpenPGP-specific rules: key parse, revocation, expiry, algorithm and
// key-size strength, encryption capability, identity presence, UID
// matching, RFC 7929 single-entity guidance and record size budget.
const pgpMaxRecordBytes = 4096
func checkPGPParseError(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil {
return []issue{{
Severity: sdk.StatusCrit,
Message: "Service body has no OPENPGPKEY record.",
Hint: "Attach a valid OPENPGPKEY record to the service.",
}}
}
if d.OpenPGP.ParseError == "" {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: d.OpenPGP.ParseError,
Hint: "Regenerate the key with `gpg --export <fpr> | base64` and paste the result; do not armor the key.",
}}
}
func checkPGPPrimaryRevoked(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || !d.OpenPGP.Revoked {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: "The OpenPGP primary key carries a revocation signature. Consumers will refuse to encrypt to it.",
Hint: "Publish a fresh, non-revoked key at this name, or withdraw the OPENPGPKEY record entirely.",
}}
}
func checkPGPPrimaryExpired(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.ExpiresAt.IsZero() {
return nil
}
if !d.OpenPGP.ExpiresAt.Before(time.Now()) {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("The OpenPGP primary key expired on %s.", d.OpenPGP.ExpiresAt.Format(time.RFC3339)),
Hint: "Extend the key's expiry (`gpg --edit-key <fpr>` → `expire`) or issue a new key and republish the OPENPGPKEY record.",
}}
}
func checkPGPPrimaryExpiring(d *EmailKeyData, opts sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.ExpiresAt.IsZero() {
return nil
}
warnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30)
if warnDays <= 0 {
return nil
}
now := time.Now()
window := time.Duration(warnDays) * 24 * time.Hour
exp := d.OpenPGP.ExpiresAt
if exp.Before(now) || exp.Sub(now) >= window {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("The OpenPGP primary key expires on %s.", exp.Format(time.RFC3339)),
Hint: "Extend the key's expiry before it lapses, then re-export and republish.",
}}
}
func checkPGPWeakAlgorithm(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil {
return nil
}
var out []issue
if isWeakPGPAlgorithm(d.OpenPGP.PrimaryAlgorithm) {
out = append(out, issue{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("Primary key uses %s, which modern OpenPGP stacks are phasing out.", d.OpenPGP.PrimaryAlgorithm),
Hint: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.",
Subject: subkeySubject(d.QueriedOwner, "primary"),
})
}
for i, sk := range d.OpenPGP.Subkeys {
if isWeakPGPAlgorithm(sk.Algorithm) {
out = append(out, issue{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("Subkey #%d uses %s, which modern OpenPGP stacks are phasing out.", i+1, sk.Algorithm),
Hint: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.",
Subject: subkeySubject(d.QueriedOwner, fmt.Sprintf("subkey-%d", i+1)),
})
}
}
return out
}
func checkPGPWeakKeySize(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil {
return nil
}
var out []issue
if iss := rsaKeySizeIssue(d.OpenPGP.PrimaryAlgorithm, d.OpenPGP.PrimaryBits, "OpenPGP primary"); iss != nil {
iss.Subject = subkeySubject(d.QueriedOwner, "primary")
out = append(out, *iss)
}
for i, sk := range d.OpenPGP.Subkeys {
if iss := rsaKeySizeIssue(sk.Algorithm, sk.Bits, fmt.Sprintf("OpenPGP subkey #%d", i+1)); iss != nil {
iss.Subject = subkeySubject(d.QueriedOwner, fmt.Sprintf("subkey-%d", i+1))
out = append(out, *iss)
}
}
return out
}
func checkPGPNoEncryption(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.ParseError != "" || d.OpenPGP.HasEncryptionCapability {
return nil
}
return []issue{{
Severity: sdk.StatusCrit,
Message: "No active (non-revoked, non-expired) key in the entity advertises encryption capability. The record is useless for email encryption.",
Hint: "Generate an encryption subkey (`gpg --edit-key <fpr>` → `addkey`) and re-export.",
}}
}
func checkPGPNoIdentity(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.ParseError != "" || len(d.OpenPGP.UIDs) > 0 {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
Message: "The OpenPGP key has no self-signed User ID. Most clients require at least one identity to bind the key to an email address.",
Hint: "Add a UID containing the user's email (e.g. `gpg --edit-key <fpr>` → `adduid`) and re-export.",
}}
}
func checkPGPUIDMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.MatchesUsername == nil || *d.OpenPGP.MatchesUsername {
return nil
}
return []issue{{
Severity: sdk.StatusInfo,
Message: fmt.Sprintf("None of the OpenPGP UIDs reference <%s@…>.", d.Username),
Hint: "Add a UID bound to the email address that the record attests to.",
}}
}
func checkPGPMultipleEntities(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.EntityCount <= 1 {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("The record contains %d OpenPGP entities; RFC 7929 recommends a single entity per OPENPGPKEY record.", d.OpenPGP.EntityCount),
Hint: "Split each user's key into its own OPENPGPKEY RR.",
}}
}
func checkPGPRecordTooLarge(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
if d.OpenPGP == nil || d.OpenPGP.RawSize <= pgpMaxRecordBytes {
return nil
}
return []issue{{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("The OpenPGP key packet is %d bytes. Large records force every resolver to fall back to TCP, slowing down the DANE lookup.", d.OpenPGP.RawSize),
Hint: "Publish only the minimum key material needed for email encryption (primary + encryption subkey) and strip image UIDs / extra attributes before export.",
}}
}
func isWeakPGPAlgorithm(name string) bool {
return name == "DSA" || name == "ElGamal"
}
// rsaKeySizeIssue returns a non-nil *issue when the given RSA key is
// below NIST's deprecation (2048) or recommendation (3072) thresholds.
// Returns nil for non-RSA algorithms or when bits is 0 (unknown).
func rsaKeySizeIssue(algorithm string, bits int, label string) *issue {
if !strings.EqualFold(algorithm, "RSA") || bits == 0 {
return nil
}
if bits < 2048 {
return &issue{
Severity: sdk.StatusCrit,
Message: fmt.Sprintf("%s RSA key of %d bits is considered broken. NIST SP 800-131A deprecates anything below 2048 bits.", label, bits),
Hint: "Generate a fresh RSA-3072/4096 or Ed25519/Curve25519 key and republish.",
}
}
if bits < 3072 {
return &issue{
Severity: sdk.StatusWarn,
Message: fmt.Sprintf("%s RSA-%d is aging; NIST recommends at least 3072 bits for new deployments.", label, bits),
Hint: "Plan a migration to RSA-3072/4096 or Ed25519/Curve25519 at the next key rotation.",
}
}
return nil
}
func subkeySubject(owner, label string) string {
if owner == "" {
return label
}
return owner + " [" + label + "]"
}