// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for // happyDomain. It runs a comprehensive testsuite on the DNS-published // OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162) corresponding // to an abstract.OpenPGP or abstract.SMimeCert service, and turns the // results into structured findings + a remediation-oriented HTML report. package checker import ( "encoding/json" "time" ) // ObservationKey is the key this checker publishes. The payload is an // *EmailKeyData JSON document. const ObservationKey = "openpgpkey_smimea" // Supported service types. const ( ServiceOpenPGP = "abstract.OpenPGP" ServiceSMimeCert = "abstract.SMimeCert" KindOpenPGPKey = "openpgpkey" KindSMIMEA = "smimea" OpenPGPKeyPrefix = "_openpgpkey" SMIMEACertPrefix = "_smimecert" DANEOwnerHashSize = 28 // bytes of SHA-256 kept as the owner prefix ) // Severity classifies a finding emitted by the checker. type Severity string const ( SeverityInfo Severity = "info" SeverityWarn Severity = "warn" SeverityCrit Severity = "crit" ) // Finding codes surfaced by the checker. These strings are stable; the // UI keys remediation templates off them. const ( // DNS-level. CodeDNSQueryFailed = "dns_query_failed" CodeDNSNoRecord = "dns_no_record" CodeDNSRecordMismatch = "dns_record_mismatch" CodeDNSNotSecure = "dnssec_not_validated" CodeOwnerHashMismatch = "owner_hash_mismatch" // OpenPGP. CodePGPParseError = "pgp_parse_error" CodePGPNoEntity = "pgp_no_entity" CodePGPRevoked = "pgp_primary_revoked" CodePGPExpired = "pgp_primary_expired" CodePGPExpiringSoon = "pgp_primary_expiring_soon" CodePGPWeakAlgorithm = "pgp_weak_algorithm" CodePGPWeakKeySize = "pgp_weak_key_size" CodePGPNoEncryption = "pgp_no_encryption_subkey" CodePGPNoIdentity = "pgp_no_identity" CodePGPUIDMismatch = "pgp_uid_mismatch" CodePGPMultipleEntities = "pgp_multiple_entities" CodePGPRecordTooLarge = "pgp_record_too_large" // SMIMEA. CodeSMIMEABadUsage = "smimea_bad_usage" CodeSMIMEABadSelector = "smimea_bad_selector" CodeSMIMEABadMatchType = "smimea_bad_match_type" CodeSMIMEACertParseError = "smimea_cert_parse_error" CodeSMIMEACertExpired = "smimea_cert_expired" CodeSMIMEACertExpiringSoon = "smimea_cert_expiring_soon" CodeSMIMEACertNotYetValid = "smimea_cert_not_yet_valid" CodeSMIMEANoEmailProtection = "smimea_no_email_protection_eku" CodeSMIMEAEmailMismatch = "smimea_email_mismatch" CodeSMIMEAWeakKeySize = "smimea_weak_key_size" CodeSMIMEAWeakSignatureAlg = "smimea_weak_signature_algorithm" CodeSMIMEANoKeyUsage = "smimea_missing_key_usage" CodeSMIMEAChainUntrusted = "smimea_chain_untrusted" CodeSMIMEASelfSigned = "smimea_self_signed" CodeSMIMEAHashOnly = "smimea_hash_only" ) // Finding describes a single observation produced while running the // testsuite. type Finding struct { Code string `json:"code"` Severity Severity `json:"severity"` Message string `json:"message"` // Fix carries a short, user-facing hint describing how to address the // issue. The HTML report falls back on generic Fix text keyed by Code // when this field is empty. Fix string `json:"fix,omitempty"` } // EmailKeyData is the observation payload written under ObservationKey. type EmailKeyData struct { // Kind is "openpgpkey" or "smimea". Kind string `json:"kind"` // Domain is the FQDN of the zone (origin) that publishes the record. Domain string `json:"domain"` // Subdomain is the relative name below Domain where the service sits // (empty for the zone apex). Subdomain string `json:"subdomain,omitempty"` // Username is the local part copied from the service. When empty, // the username-hash-prefix verification is skipped. Username string `json:"username,omitempty"` // ExpectedOwner is the FQDN at which the record should be // published, per RFC 7929 / RFC 8162. ExpectedOwner string `json:"expected_owner,omitempty"` // QueriedOwner is the FQDN actually queried (may differ from // ExpectedOwner if the service record already carries its own name). QueriedOwner string `json:"queried_owner,omitempty"` // Resolver is the DNS server that answered the lookup. Resolver string `json:"resolver,omitempty"` // DNSSECSecure is true when the validating resolver set the AD flag // on the answer. Nil means the lookup did not complete. DNSSECSecure *bool `json:"dnssec_secure,omitempty"` // RecordCount is the number of records returned at QueriedOwner. RecordCount int `json:"record_count"` // OpenPGP is populated for kind=openpgpkey. OpenPGP *OpenPGPInfo `json:"openpgp,omitempty"` // SMIMEA is populated for kind=smimea. SMIMEA *SMIMEAInfo `json:"smimea,omitempty"` Findings []Finding `json:"findings"` CollectedAt time.Time `json:"collected_at"` } // OpenPGPInfo summarises the OpenPGP key observed in the record. type OpenPGPInfo struct { // RawSize is the length in bytes of the transport key material. RawSize int `json:"raw_size"` // PrimaryAlgorithm is the name of the primary key's algorithm, // e.g. "RSA", "Ed25519", "ECDSA-NIST-P-256". PrimaryAlgorithm string `json:"primary_algorithm,omitempty"` // PrimaryBits is the key size in bits for the primary key (0 when // the algorithm is of fixed size, e.g. Ed25519). PrimaryBits int `json:"primary_bits,omitempty"` // Fingerprint is the hex-encoded OpenPGP fingerprint. Fingerprint string `json:"fingerprint,omitempty"` // KeyID is the short 64-bit key id, hex. KeyID string `json:"key_id,omitempty"` // UIDs lists the User ID strings carried in the key. UIDs []string `json:"uids,omitempty"` // CreatedAt is the primary key creation time. CreatedAt time.Time `json:"created_at,omitempty"` // ExpiresAt is the primary key expiration time (zero for "never"). ExpiresAt time.Time `json:"expires_at,omitempty"` // Revoked is true when the primary key carries a revocation signature. Revoked bool `json:"revoked,omitempty"` // Subkeys describes the subordinate keys. Subkeys []SubkeyInfo `json:"subkeys,omitempty"` // EntityCount is the number of OpenPGP entities parsed from the // record. RFC 7929 recommends a single entity per record. EntityCount int `json:"entity_count"` // HasEncryptionCapability is true when at least one non-revoked, // non-expired key in the entity advertises encryption usage flags. HasEncryptionCapability bool `json:"has_encryption_capability"` } // SubkeyInfo summarises one OpenPGP subkey. type SubkeyInfo struct { Algorithm string `json:"algorithm"` Bits int `json:"bits,omitempty"` CanSign bool `json:"can_sign,omitempty"` CanEncrypt bool `json:"can_encrypt,omitempty"` CanAuth bool `json:"can_auth,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"` Revoked bool `json:"revoked,omitempty"` } // SMIMEAInfo summarises the S/MIME record. type SMIMEAInfo struct { Usage uint8 `json:"usage"` Selector uint8 `json:"selector"` MatchingType uint8 `json:"matching_type"` // Certificate is populated when the record carries a full X.509 // certificate (selector 0, matching type 0). For selector 1 + type 0 // only PublicKey is populated. For matching types 1/2, neither is // populated; only the digest is transported. Certificate *CertInfo `json:"certificate,omitempty"` PublicKey *PubKeyInfo `json:"public_key,omitempty"` // HashHex, when set, is the hex digest embedded in the record. HashHex string `json:"hash_hex,omitempty"` } // CertInfo summarises an X.509 certificate. type CertInfo struct { Subject string `json:"subject,omitempty"` Issuer string `json:"issuer,omitempty"` SerialHex string `json:"serial_hex,omitempty"` NotBefore time.Time `json:"not_before,omitempty"` NotAfter time.Time `json:"not_after,omitempty"` SignatureAlgorithm string `json:"signature_algorithm,omitempty"` PublicKeyAlgorithm string `json:"public_key_algorithm,omitempty"` PublicKeyBits int `json:"public_key_bits,omitempty"` EmailAddresses []string `json:"email_addresses,omitempty"` DNSNames []string `json:"dns_names,omitempty"` HasEmailProtectionEKU bool `json:"has_email_protection_eku,omitempty"` HasDigitalSignature bool `json:"has_digital_signature,omitempty"` HasKeyEncipherment bool `json:"has_key_encipherment,omitempty"` IsSelfSigned bool `json:"is_self_signed,omitempty"` IsCA bool `json:"is_ca,omitempty"` } // PubKeyInfo summarises an SPKI-only SMIMEA record. type PubKeyInfo struct { Algorithm string `json:"algorithm,omitempty"` Bits int `json:"bits,omitempty"` } // serviceMessage is a minimal mirror of happyDomain's ServiceMessage JSON // envelope used to carry the auto-filled service. type serviceMessage struct { Type string `json:"_svctype"` Domain string `json:"_domain"` Service json.RawMessage `json:"Service"` }