219 lines
8.6 KiB
Go
219 lines
8.6 KiB
Go
// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for
|
|
// happyDomain. It gathers the facts published by a zone for an
|
|
// abstract.OpenPGP or abstract.SMimeCert service (DNS lookup, DNSSEC
|
|
// flag, parsed OpenPGP key, parsed X.509 certificate) and lets a
|
|
// family of per-test rules judge those facts.
|
|
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
|
|
)
|
|
|
|
// EmailKeyData is the observation payload written under ObservationKey.
|
|
// It carries only facts; no severities, no judgment, rules decide
|
|
// what's OK and what isn't.
|
|
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"`
|
|
|
|
// DNSQueryError is non-empty when the DNS lookup itself failed (no
|
|
// answer received, transport error, etc.).
|
|
DNSQueryError string `json:"dns_query_error,omitempty"`
|
|
|
|
// DNSAnswerPresent is nil when the lookup did not complete, false
|
|
// when the authoritative answer was NXDOMAIN / empty, true otherwise.
|
|
DNSAnswerPresent *bool `json:"dns_answer_present,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"`
|
|
|
|
// DNSRecordMatchesService is the result of comparing the DNS-returned
|
|
// record bytes against the service-body bytes. Nil when the
|
|
// comparison could not run (DNS failed, or the service body has no
|
|
// record to compare against).
|
|
DNSRecordMatchesService *bool `json:"dns_record_matches_service,omitempty"`
|
|
|
|
// ObservedOwnerPrefix is the hash-shaped first label extracted from
|
|
// QueriedOwner (<hex>._openpgpkey.<…> / <hex>._smimecert.<…>), or
|
|
// empty when the owner does not follow that shape.
|
|
ObservedOwnerPrefix string `json:"observed_owner_prefix,omitempty"`
|
|
|
|
// ExpectedOwnerPrefix is hex(sha256(Username))[:28]. Empty when
|
|
// Username is empty.
|
|
ExpectedOwnerPrefix string `json:"expected_owner_prefix,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"`
|
|
|
|
CollectedAt time.Time `json:"collected_at"`
|
|
}
|
|
|
|
// OpenPGPInfo summarises the OpenPGP key observed in the record.
|
|
type OpenPGPInfo struct {
|
|
// ParseError is non-empty when the record could not be decoded as a
|
|
// valid OpenPGP key (bad base64, unreadable packet stream, no
|
|
// entity, or no record attached to the service at all). Remaining
|
|
// fields may be zero-valued on this path.
|
|
ParseError string `json:"parse_error,omitempty"`
|
|
|
|
// 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"`
|
|
|
|
// MatchesUsername is nil when the check was not run (no UIDs or no
|
|
// username), true when at least one UID references <username@…>,
|
|
// false otherwise.
|
|
MatchesUsername *bool `json:"matches_username,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 {
|
|
// ParseError is non-empty when the certificate / SPKI bytes cannot
|
|
// be parsed.
|
|
ParseError string `json:"parse_error,omitempty"`
|
|
|
|
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"`
|
|
|
|
// EmailMatchesUsername is nil when the check was not run (no
|
|
// username or no email SAN on the certificate), true when at least
|
|
// one SAN begins with "<username>@", false otherwise.
|
|
EmailMatchesUsername *bool `json:"email_matches_username,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"`
|
|
}
|