213 lines
7.3 KiB
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 + "]"
|
|
}
|