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 | 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 ` → `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 ` → `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 ` → `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 + "]" }