diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index ba70baf..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -1,22 +0,0 @@ -image: happydomain/checker-email-keys:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} -{{#if build.tags}} -tags: -{{#each build.tags}} - - {{this}} -{{/each}} -{{/if}} -manifests: - - image: happydomain/checker-email-keys:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 - platform: - architecture: amd64 - os: linux - - image: happydomain/checker-email-keys:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 - platform: - architecture: arm64 - os: linux - variant: v8 - - image: happydomain/checker-email-keys:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm - platform: - architecture: arm - os: linux - variant: v7 diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 8f432d9..0000000 --- a/.drone.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -kind: pipeline -type: docker -name: build-amd64 - -platform: - os: linux - arch: amd64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-email-keys - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-email-keys - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - ---- -kind: pipeline -type: docker -name: build-arm64 - -platform: - os: linux - arch: arm64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-email-keys - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-email-keys - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - event: - - cron - - push - - tag - ---- -kind: pipeline -name: docker-manifest - -platform: - os: linux - arch: arm64 - -steps: - - name: publish on Docker Hub - image: plugins/manifest - settings: - auto_tag: true - ignore_missing: true - spec: .drone-manifest.yml - username: - from_secret: docker_username - password: - from_secret: docker_password - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - -depends_on: - - build-amd64 - - build-arm64 diff --git a/Dockerfile b/Dockerfile index 5ac04d7..112385c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,9 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-email-keys . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-email-keys . FROM scratch COPY --from=builder /checker-email-keys /checker-email-keys -USER 65534:65534 EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD ["/checker-email-keys", "-healthcheck"] ENTRYPOINT ["/checker-email-keys"] diff --git a/Makefile b/Makefile index a2216c5..b1b1b12 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go) GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) -.PHONY: all plugin docker test clean +.PHONY: all plugin docker clean all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -21,8 +21,5 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) docker: docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . -test: - go test -tags standalone ./... - clean: rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/README.md b/README.md index 5ccd72c..eebb76b 100644 --- a/README.md +++ b/README.md @@ -18,58 +18,53 @@ This checker binds to the happyDomain services: [rfc7929]: https://www.rfc-editor.org/rfc/rfc7929 [rfc8162]: https://www.rfc-editor.org/rfc/rfc8162 -## Security scope +## Tests run -This checker validates DNS publication and the structure/metadata of the -keys it finds. It does **not** cryptographically verify them: +All findings are tagged by severity (`info` / `warn` / `crit`) so the +rule engine can fold them into a single `CheckState`. -- OpenPGP signatures (self-signatures, third-party certifications, - revocations beyond the presence of a revocation packet) are **not** - verified. -- S/MIME certificate chains are **not** built or validated against any - trust anchor; revocation (CRL/OCSP) is **not** checked. -- Authenticity of the records themselves is delegated to the - validating resolver via the DNSSEC `AD` flag (see - `dnssec_not_validated`). Run the checker against a resolver you - trust to perform DNSSEC validation. +### DNS (both record types) -Treat a green report as "the record is well-formed and DNSSEC-signed", -not as "the key is trustworthy". +| Code | Severity | What it catches | +| --- | --- | --- | +| `dns_query_failed` | crit | The resolver returned an error or did not answer. | +| `dns_no_record` | crit | The authoritative answer has no record at the expected owner. | +| `dnssec_not_validated` | crit / warn | The validating resolver did not set `AD`. RFC 7929/8162 mandate DNSSEC; the severity is configurable via `requireDNSSEC`. | +| `dns_record_mismatch` | warn | The record returned by DNS differs from the one declared in the service (typically a stale zone on the authoritative servers). | +| `owner_hash_mismatch` | crit | Record owner-name first label is not `sha256(localpart)[:28]`; mail clients will never find it. | -## Rules +### OpenPGP-specific (RFC 7929) -| Code | Description | Severity | -|-----------------------------------|---------------------------------------------------------------------------------------------------|---------------------| -| `dns_query_failed` | Verifies that the DNS lookup for the OPENPGPKEY/SMIMEA record succeeds. | CRITICAL | -| `dns_no_record` | Verifies that an OPENPGPKEY/SMIMEA record is published at the expected owner name. | CRITICAL | -| `dns_record_mismatch` | Verifies that the record returned by DNS matches the service-declared record. | WARNING | -| `dnssec_not_validated` | Verifies that the record is authenticated by DNSSEC (AD flag set). | CRITICAL | -| `owner_hash_mismatch` | Verifies that the owner-name first label equals hex(sha256(username))[:28]. | CRITICAL | -| `pgp_parse_error` | Verifies that the OPENPGPKEY record decodes as a valid OpenPGP key. | CRITICAL | -| `pgp_primary_revoked` | Verifies that the OpenPGP primary key carries no revocation signature. | CRITICAL | -| `pgp_primary_expired` | Verifies that the OpenPGP primary key has not passed its self-signature expiry. | CRITICAL | -| `pgp_primary_expiring_soon` | Warns when the OpenPGP primary key expires within the configured window. | WARNING | -| `pgp_weak_algorithm` | Verifies that OpenPGP keys do not use legacy algorithms (DSA/ElGamal). | WARNING | -| `pgp_weak_key_size` | Verifies that OpenPGP RSA keys meet the minimum 2048-bit size (3072+ preferred). | CRITICAL | -| `pgp_no_encryption_subkey` | Verifies that at least one active OpenPGP key advertises encryption capability. | CRITICAL | -| `pgp_no_identity` | Verifies that the OpenPGP key carries at least one self-signed User ID. | WARNING | -| `pgp_uid_mismatch` | Checks that at least one OpenPGP UID references . | INFO | -| `pgp_multiple_entities` | Verifies that the record carries a single OpenPGP entity (RFC 7929). | WARNING | -| `pgp_record_too_large` | Verifies that the OPENPGPKEY record stays below 4 KiB to fit typical UDP answers. | WARNING | -| `smimea_bad_usage` | Verifies that the SMIMEA usage field is 0, 1, 2, or 3. | CRITICAL | -| `smimea_bad_selector` | Verifies that the SMIMEA selector field is 0 (Cert) or 1 (SPKI). | CRITICAL | -| `smimea_bad_match_type` | Verifies that the SMIMEA matching type is 0 (Full), 1 (SHA-256), or 2 (SHA-512). | CRITICAL | -| `smimea_cert_parse_error` | Verifies that the SMIMEA record decodes as a valid X.509 certificate or SPKI. | CRITICAL | -| `smimea_cert_not_yet_valid` | Verifies that the S/MIME certificate's NotBefore is in the past. | CRITICAL | -| `smimea_cert_expired` | Verifies that the S/MIME certificate's NotAfter is in the future. | CRITICAL | -| `smimea_cert_expiring_soon` | Warns when the S/MIME certificate expires within the configured window. | WARNING | -| `smimea_no_email_protection_eku` | Verifies that the S/MIME certificate advertises the emailProtection EKU. | CRITICAL | -| `smimea_missing_key_usage` | Verifies that the certificate carries digitalSignature and/or keyEncipherment key usage. | WARNING | -| `smimea_weak_signature_algorithm` | Verifies that the certificate is not signed with a deprecated algorithm (MD2/MD5/SHA-1). | CRITICAL | -| `smimea_weak_key_size` | Verifies that SMIMEA RSA keys meet the minimum 2048-bit size (3072+ preferred). | CRITICAL | -| `smimea_self_signed` | Flags self-signed certificates paired with PKIX-EE (usage 1). | INFO | -| `smimea_email_mismatch` | Checks that at least one email SAN on the certificate begins with @. | INFO | -| `smimea_hash_only` | Notes that SMIMEA matching types 1/2 transport only a digest, preventing certificate inspection. | INFO | +| Code | Severity | What it catches | +| --- | --- | --- | +| `pgp_parse_error` | crit | Malformed base64 or OpenPGP packet stream. | +| `pgp_no_entity` | crit | Record decoded but carries no valid entity. | +| `pgp_primary_revoked` | crit | Primary key has a revocation signature. | +| `pgp_primary_expired` | crit | Self-signature expired; clients will refuse to encrypt. | +| `pgp_primary_expiring_soon` | warn | Expires within the `certExpiryWarnDays` window (default 30). | +| `pgp_weak_algorithm` | warn | Uses DSA / ElGamal (phase-out). | +| `pgp_weak_key_size` | crit / warn | RSA below 2048 bits is critical, 2048-3071 is a warn. | +| `pgp_no_encryption_subkey` | crit | No active key in the entity advertises encryption capability. | +| `pgp_no_identity` | warn | No self-signed User ID. | +| `pgp_uid_mismatch` | info | None of the UIDs reference ``. | +| `pgp_multiple_entities` | warn | Record carries more than one entity (RFC 7929 recommends one). | +| `pgp_record_too_large` | warn | Raw key > 4 KiB; forces UDP→TCP fallback on every lookup. | + +### SMIMEA-specific (RFC 8162) + +| Code | Severity | What it catches | +| --- | --- | --- | +| `smimea_bad_usage` / `_selector` / `_match_type` | crit | Field outside the allowed range. | +| `smimea_cert_parse_error` | crit | Hex-encoded blob is not a valid X.509 certificate / SPKI. | +| `smimea_cert_expired` / `_not_yet_valid` | crit | `notBefore` / `notAfter` gate the current time out. | +| `smimea_cert_expiring_soon` | warn | Within the `certExpiryWarnDays` window. | +| `smimea_no_email_protection_eku` | crit / warn | Missing `emailProtection` EKU (RFC 8550/8551 agents will reject). | +| `smimea_missing_key_usage` | warn | Neither `digitalSignature` nor `keyEncipherment` key-usage is set. | +| `smimea_email_mismatch` | info | No email SAN starts with `@`. | +| `smimea_weak_signature_algorithm` | crit | MD5 / SHA-1 based signature. | +| `smimea_weak_key_size` | crit / warn | RSA < 2048 / 3072 bits. | +| `smimea_self_signed` | info | Self-signed certificate paired with PKIX-EE usage. | +| `smimea_hash_only` | info | Matching-type 1/2 only carries a digest; certificate can't be inspected. | ## Options diff --git a/checker/collect.go b/checker/collect.go index cc30469..2d87b00 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -24,14 +24,6 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// maxKeyMaterialBytes caps the decoded byte size of an OPENPGPKEY -// payload or an SMIMEA certificate before it is handed to the parser. -// Anything larger is rejected outright to keep parser costs bounded; a -// rule (e.g. RulePGPRecordTooLarge at 4 KiB) flags more conservative -// limits separately. 64 KiB is well above any legitimate OpenPGP key -// size while staying clear of pathological input. -const maxKeyMaterialBytes = 64 * 1024 - // serviceBody is the common envelope for the two services. type serviceBody struct { Username string `json:"username,omitempty"` @@ -39,11 +31,12 @@ type serviceBody struct { SMIMEA *dns.SMIMEA `json:"smimea,omitempty"` } -// Collect runs the DANE-email data gathering pipeline and returns an -// *EmailKeyData carrying raw facts (DNS outcome, parsed key / cert -// structure). Judgment, severity, fix hints, option-driven thresholds, -// is deferred to the rules. A non-nil error is returned only for -// unrecoverable input problems (missing options, unknown service type). +// Collect runs the full DANE-email testsuite and returns an *EmailKeyData +// carrying every finding it produced. The function never returns an error +// for domain-level problems; they are recorded as findings so that a +// subsequent call from the rule can fold them into a single CheckState. +// A non-nil error is returned only for unrecoverable input problems +// (missing options, unknown service type). func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { svcMsg, err := serviceFromOptions(opts) if err != nil { @@ -63,6 +56,9 @@ func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) originOpt, _ := sdk.GetOption[string](opts, "domain_name") subdomainOpt, _ := sdk.GetOption[string](opts, "subdomain") resolverOpt, _ := sdk.GetOption[string](opts, OptionResolver) + expiryWarnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30) + requireDNSSEC := sdk.GetBoolOption(opts, OptionRequireDNSSEC, true) + requireEmailProtection := sdk.GetBoolOption(opts, OptionRequireEmailProtection, true) origin := strings.TrimSuffix(firstNonEmpty(originOpt, svcMsg.Domain), ".") if origin == "" { @@ -86,10 +82,17 @@ func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) data.ExpectedOwner = expectedOwner data.QueriedOwner = firstNonEmpty(recordedOwner, expectedOwner) - // Owner-name hash inputs: rules compare the two and decide. + // Username hash prefix verification (RFC 7929 §3, RFC 8162 §3). if data.Username != "" { - data.ExpectedOwnerPrefix = ownerHashHex(data.Username) - data.ObservedOwnerPrefix = extractOwnerPrefix(data.QueriedOwner, prefix, parent) + actualPrefix, want := extractOwnerPrefix(data.QueriedOwner, prefix, parent), ownerHashHex(data.Username) + if actualPrefix != "" && !strings.EqualFold(actualPrefix, want) { + data.Findings = append(data.Findings, Finding{ + Code: CodeOwnerHashMismatch, + Severity: SeverityCrit, + Message: fmt.Sprintf("Owner name prefix %q does not match SHA-256(%q)[:28]=%q.", actualPrefix, data.Username, want), + Fix: "Republish the record at the hash-derived name for the intended user, or update the Username field to match the record's owner name.", + }) + } } // DNS lookup + DNSSEC flag. @@ -101,38 +104,63 @@ func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) } ans, err := lookup(ctx, servers, data.QueriedOwner, qtype) if err != nil { - data.DNSQueryError = fmt.Sprintf("DNS lookup for %s %s failed: %v", dns.TypeToString[qtype], data.QueriedOwner, err) + data.Findings = append(data.Findings, Finding{ + Code: CodeDNSQueryFailed, + Severity: SeverityCrit, + Message: fmt.Sprintf("DNS lookup for %s %s failed: %v", dns.TypeToString[qtype], data.QueriedOwner, err), + Fix: "Check that the zone is published at an authoritative server reachable from this checker.", + }) } else { data.Resolver = ans.Server secure := ans.AD data.DNSSECSecure = &secure data.RecordCount = len(ans.Records) - present := !(ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0) - data.DNSAnswerPresent = &present - - // Compare DNS-returned record bytes with the service-declared ones - // only when we actually have records to compare and a reference. - if present { - var match bool - switch { - case kind == KindOpenPGPKey && body.OpenPGP != nil: - match = anyOpenPGPMatches(ans.Records, body.OpenPGP) - data.DNSRecordMatchesService = &match - case kind == KindSMIMEA && body.SMIMEA != nil: - match = anySMIMEAMatches(ans.Records, body.SMIMEA) - data.DNSRecordMatchesService = &match + if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 { + data.Findings = append(data.Findings, Finding{ + Code: CodeDNSNoRecord, + Severity: SeverityCrit, + Message: fmt.Sprintf("Authoritative DNS returned no %s record at %s.", dns.TypeToString[qtype], data.QueriedOwner), + Fix: "Ensure the record is present in the zone and that the zone has been loaded by the authoritative servers.", + }) + } else { + if !ans.AD { + sev := SeverityWarn + if requireDNSSEC { + sev = SeverityCrit + } + data.Findings = append(data.Findings, Finding{ + Code: CodeDNSNotSecure, + Severity: sev, + Message: "The validating resolver did not set the AD flag: the record is not DNSSEC-authenticated, which defeats the whole DANE trust model.", + Fix: "Sign the zone with DNSSEC and publish the DS record at the parent so RFC 7929/8162 consumers can authenticate the key.", + }) + } + // Compare observed record with the service-published one. + mismatch := false + if kind == KindOpenPGPKey && body.OpenPGP != nil { + mismatch = !anyOpenPGPMatches(ans.Records, body.OpenPGP) + } else if kind == KindSMIMEA && body.SMIMEA != nil { + mismatch = !anySMIMEAMatches(ans.Records, body.SMIMEA) + } + if mismatch { + data.Findings = append(data.Findings, Finding{ + Code: CodeDNSRecordMismatch, + Severity: SeverityWarn, + Message: "The record returned by DNS does not match the one declared in the service. The zone may not have been re-published since the last edit.", + Fix: "Propagate the zone to the authoritative servers, then wait for TTL/negative-cache expiry.", + }) } } } } - // Parse the payload from the service body (so rules can evaluate even - // when the DNS lookup failed to reach the authoritative servers). + // Parse the payload from the service body (so we can analyze even if + // DNS lookup failed to reach the authoritative servers). if kind == KindOpenPGPKey { - data.OpenPGP = analyzeOpenPGP(body) + data.OpenPGP, data.Findings = analyzeOpenPGP(body, data.Findings, time.Duration(expiryWarnDays)*24*time.Hour) } else { - data.SMIMEA = analyzeSMIMEA(body) + data.SMIMEA, data.Findings = analyzeSMIMEA(body, data.Findings, time.Duration(expiryWarnDays)*24*time.Hour, requireEmailProtection) } return data, nil @@ -193,13 +221,11 @@ func computeOwner(body serviceBody, prefix, parent string) (expected, recorded s // Normalise: no double dots. expected = strings.Replace(expected, "..", ".", -1) } - // happyDomain encodes service-embedded record owners relative to the - // parent zone, so we must join with parent before treating as FQDN. switch { case body.OpenPGP != nil && body.OpenPGP.Hdr.Name != "": - recorded = dns.Fqdn(sdk.JoinRelative(body.OpenPGP.Hdr.Name, parent)) + recorded = dns.Fqdn(body.OpenPGP.Hdr.Name) case body.SMIMEA != nil && body.SMIMEA.Hdr.Name != "": - recorded = dns.Fqdn(sdk.JoinRelative(body.SMIMEA.Hdr.Name, parent)) + recorded = dns.Fqdn(body.SMIMEA.Hdr.Name) } return } @@ -249,45 +275,69 @@ func anySMIMEAMatches(rrs []dns.RR, ref *dns.SMIMEA) bool { // ── OpenPGP analysis ───────────────────────────────────────────────────────── // analyzeOpenPGP parses the OpenPGP key from the service record and -// returns a structured fact summary. When parsing fails, ParseError is -// populated and the rest of the fields hold whatever could be recovered. -func analyzeOpenPGP(body serviceBody) *OpenPGPInfo { +// emits findings. It returns the summary and the (possibly extended) +// findings list. +func analyzeOpenPGP(body serviceBody, findings []Finding, expiryWarn time.Duration) (*OpenPGPInfo, []Finding) { if body.OpenPGP == nil { - return &OpenPGPInfo{ParseError: "Service body has no OPENPGPKEY record."} + findings = append(findings, Finding{ + Code: CodePGPParseError, + Severity: SeverityCrit, + Message: "Service body has no OPENPGPKEY record.", + Fix: "Attach a valid OPENPGPKEY record to the service.", + }) + return nil, findings } - encoded := body.OpenPGP.PublicKey - // Reject pathological payloads before allocating: the base64-decoded - // size is at most ceil(len(encoded)*3/4). - if len(encoded)/4*3 > maxKeyMaterialBytes { - return &OpenPGPInfo{ - RawSize: len(encoded) / 4 * 3, - ParseError: fmt.Sprintf("OPENPGPKEY payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes), - } - } - raw, err := base64.StdEncoding.DecodeString(encoded) + raw, err := base64.StdEncoding.DecodeString(body.OpenPGP.PublicKey) if err != nil { - return &OpenPGPInfo{ParseError: fmt.Sprintf("OPENPGPKEY record carries invalid base64: %v", err)} - } - if len(raw) > maxKeyMaterialBytes { - return &OpenPGPInfo{ - RawSize: len(raw), - ParseError: fmt.Sprintf("OPENPGPKEY payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes), - } + findings = append(findings, Finding{ + Code: CodePGPParseError, + Severity: SeverityCrit, + Message: fmt.Sprintf("OPENPGPKEY record carries invalid base64: %v", err), + Fix: "Re-export the public key as a binary OpenPGP packet stream (no ASCII armor) and base64 it exactly as stored in the RDATA.", + }) + return nil, findings } info := &OpenPGPInfo{RawSize: len(raw)} + // Large records get fragmented over UDP and force TCP re-queries. + // RFC 7929 is silent on the exact threshold; >1200 bytes is a + // reasonable "will not fit in a typical UDP answer" line. + if len(raw) > 4096 { + findings = append(findings, Finding{ + Code: CodePGPRecordTooLarge, + Severity: SeverityWarn, + 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.", len(raw)), + Fix: "Publish only the minimum key material needed for email encryption (primary + encryption subkey) and strip image UIDs / extra attributes before export.", + }) + } + entities, err := openpgp.ReadKeyRing(bytes.NewReader(raw)) if err != nil || len(entities) == 0 { + // Fallback: try parsing as a single packet stream; some + // implementations omit markers between entities. if err == nil { err = fmt.Errorf("no OpenPGP entity found") } - info.ParseError = fmt.Sprintf("Cannot parse OpenPGP key: %v", err) - return info + findings = append(findings, Finding{ + Code: CodePGPParseError, + Severity: SeverityCrit, + Message: fmt.Sprintf("Cannot parse OpenPGP key: %v", err), + Fix: "Regenerate the key with `gpg --export | base64` and paste the result; do not armor the key.", + }) + return info, findings } info.EntityCount = len(entities) + if len(entities) > 1 { + findings = append(findings, Finding{ + Code: CodePGPMultipleEntities, + Severity: SeverityWarn, + Message: fmt.Sprintf("The record contains %d OpenPGP entities; RFC 7929 recommends a single entity per OPENPGPKEY record.", len(entities)), + Fix: "Split each user's key into its own OPENPGPKEY RR.", + }) + } ent := entities[0] pub := ent.PrimaryKey @@ -297,24 +347,55 @@ func analyzeOpenPGP(body serviceBody) *OpenPGPInfo { info.PrimaryAlgorithm = algorithmName(pub) info.PrimaryBits = publicKeyBits(pub) + // Identity UIDs. for name := range ent.Identities { info.UIDs = append(info.UIDs, name) } + // Revocations on the primary key. if len(ent.Revocations) > 0 { info.Revoked = true + findings = append(findings, Finding{ + Code: CodePGPRevoked, + Severity: SeverityCrit, + Message: "The OpenPGP primary key carries a revocation signature. Consumers will refuse to encrypt to it.", + Fix: "Publish a fresh, non-revoked key at this name, or withdraw the OPENPGPKEY record entirely.", + }) } // Expiry on the primary key, derived from the self-signature. now := time.Now() if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil { if selfSig.KeyLifetimeSecs != nil && *selfSig.KeyLifetimeSecs > 0 { - info.ExpiresAt = pub.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second) + exp := pub.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second) + info.ExpiresAt = exp + if exp.Before(now) { + findings = append(findings, Finding{ + Code: CodePGPExpired, + Severity: SeverityCrit, + Message: fmt.Sprintf("The OpenPGP primary key expired on %s.", exp.Format(time.RFC3339)), + Fix: "Extend the key's expiry (`gpg --edit-key ` → `expire`) or issue a new key and republish the OPENPGPKEY record.", + }) + } else if expiryWarn > 0 && exp.Sub(now) < expiryWarn { + findings = append(findings, Finding{ + Code: CodePGPExpiringSoon, + Severity: SeverityWarn, + Message: fmt.Sprintf("The OpenPGP primary key expires on %s.", exp.Format(time.RFC3339)), + Fix: "Extend the key's expiry before it lapses, then re-export and republish.", + }) + } } } - // UID vs username matching. - if len(ent.Identities) > 0 && body.Username != "" { + // Identity presence and UID vs username matching. + if len(ent.Identities) == 0 { + findings = append(findings, Finding{ + Code: CodePGPNoIdentity, + Severity: SeverityWarn, + 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.", + Fix: "Add a UID containing the user's email (e.g. `gpg --edit-key ` → `adduid`) and re-export.", + }) + } else if body.Username != "" { wantedLocal := strings.ToLower(body.Username) matched := false for name := range ent.Identities { @@ -324,7 +405,19 @@ func analyzeOpenPGP(body serviceBody) *OpenPGPInfo { break } } - info.MatchesUsername = &matched + if !matched { + findings = append(findings, Finding{ + Code: CodePGPUIDMismatch, + Severity: SeverityInfo, + Message: fmt.Sprintf("None of the OpenPGP UIDs reference <%s@…>.", body.Username), + Fix: "Add a UID bound to the email address that the record attests to.", + }) + } + } + + // Primary key algorithm + size checks. + if warn := pgpAlgorithmWarning(pub); warn != nil { + findings = append(findings, *warn) } // Subkeys + encryption capability. @@ -350,6 +443,9 @@ func analyzeOpenPGP(body serviceBody) *OpenPGPInfo { if si.CanEncrypt && !si.Revoked && (si.ExpiresAt.IsZero() || si.ExpiresAt.After(now)) { info.HasEncryptionCapability = true } + if warn := pgpAlgorithmWarning(sk.PublicKey); warn != nil { + findings = append(findings, *warn) + } } // Primary can also be an encryption key if flagged so. if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil && selfSig.FlagsValid && @@ -357,8 +453,16 @@ func analyzeOpenPGP(body serviceBody) *OpenPGPInfo { !info.Revoked && (info.ExpiresAt.IsZero() || info.ExpiresAt.After(now)) { info.HasEncryptionCapability = true } + if !info.HasEncryptionCapability { + findings = append(findings, Finding{ + Code: CodePGPNoEncryption, + Severity: SeverityCrit, + Message: "No active (non-revoked, non-expired) key in the entity advertises encryption capability. The record is useless for email encryption.", + Fix: "Generate an encryption subkey (`gpg --edit-key ` → `addkey`) and re-export.", + }) + } - return info + return info, findings } func algorithmName(pub *packet.PublicKey) string { @@ -418,13 +522,53 @@ func publicKeyBits(pub *packet.PublicKey) int { return 0 } +func pgpAlgorithmWarning(pub *packet.PublicKey) *Finding { + switch pub.PubKeyAlgo { + case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSAEncryptOnly, packet.PubKeyAlgoRSASignOnly: + bits := publicKeyBits(pub) + if bits == 0 { + return nil + } + if bits < 2048 { + return &Finding{ + Code: CodePGPWeakKeySize, + Severity: SeverityCrit, + Message: fmt.Sprintf("RSA key of %d bits is considered broken. NIST SP 800-131A deprecates anything below 2048 bits.", bits), + Fix: "Generate a fresh RSA-3072/4096 or Ed25519/Curve25519 key and republish.", + } + } + if bits < 3072 { + return &Finding{ + Code: CodePGPWeakKeySize, + Severity: SeverityWarn, + Message: fmt.Sprintf("RSA-%d is aging; NIST recommends at least 3072 bits for new deployments.", bits), + Fix: "Plan a migration to RSA-3072/4096 or Ed25519/Curve25519 at the next key rotation.", + } + } + case packet.PubKeyAlgoDSA, packet.PubKeyAlgoElGamal: + return &Finding{ + Code: CodePGPWeakAlgorithm, + Severity: SeverityWarn, + Message: fmt.Sprintf("Primary/subkey uses %s, which modern OpenPGP stacks are phasing out.", algorithmName(pub)), + Fix: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.", + } + } + return nil +} + // ── SMIMEA analysis ────────────────────────────────────────────────────────── -// analyzeSMIMEA parses the SMIMEA certificate and returns a structured -// fact summary. When parsing fails, ParseError is populated. -func analyzeSMIMEA(body serviceBody) *SMIMEAInfo { +// analyzeSMIMEA parses the SMIMEA certificate, computes a structured +// summary, and emits findings. +func analyzeSMIMEA(body serviceBody, findings []Finding, expiryWarn time.Duration, requireEmailProtection bool) (*SMIMEAInfo, []Finding) { if body.SMIMEA == nil { - return &SMIMEAInfo{ParseError: "Service body has no SMIMEA record."} + findings = append(findings, Finding{ + Code: CodeSMIMEACertParseError, + Severity: SeverityCrit, + Message: "Service body has no SMIMEA record.", + Fix: "Attach a valid SMIMEA record to the service.", + }) + return nil, findings } rec := body.SMIMEA @@ -435,30 +579,60 @@ func analyzeSMIMEA(body serviceBody) *SMIMEAInfo { HashHex: strings.ToLower(rec.Certificate), } - // Matching types 1 and 2 only carry a digest; no certificate or SPKI - // to parse. Rules surface that; here we just stop. - if rec.MatchingType != 0 { - return info + // Usage (RFC 6698 + 8162): 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE. + if rec.Usage > 3 { + findings = append(findings, Finding{ + Code: CodeSMIMEABadUsage, + Severity: SeverityCrit, + Message: fmt.Sprintf("Unknown SMIMEA usage %d (expected 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE).", rec.Usage), + Fix: "Use usage 3 (DANE-EE) for self-hosted S/MIME certificates.", + }) + } + if rec.Selector > 1 { + findings = append(findings, Finding{ + Code: CodeSMIMEABadSelector, + Severity: SeverityCrit, + Message: fmt.Sprintf("Unknown SMIMEA selector %d (expected 0 Cert or 1 SPKI).", rec.Selector), + Fix: "Use selector 0 to publish the full certificate.", + }) + } + if rec.MatchingType > 2 { + findings = append(findings, Finding{ + Code: CodeSMIMEABadMatchType, + Severity: SeverityCrit, + Message: fmt.Sprintf("Unknown SMIMEA matching type %d (expected 0 Full, 1 SHA-256, 2 SHA-512).", rec.MatchingType), + Fix: "Use matching type 0 so the whole certificate is transported, or type 1 (SHA-256) for a digest.", + }) } - if len(rec.Certificate)/2 > maxKeyMaterialBytes { - info.ParseError = fmt.Sprintf("SMIMEA payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes) - return info + // Matching types 1 and 2 only carry a digest; no certificate to + // parse. Surface that as info so the user knows the checker's + // findings are limited. + if rec.MatchingType != 0 { + findings = append(findings, Finding{ + Code: CodeSMIMEAHashOnly, + Severity: SeverityInfo, + Message: "Record carries only a digest; the certificate itself cannot be verified by this checker.", + Fix: "Switch to matching type 0 (Full) to let verifiers inspect and pin the certificate.", + }) + return info, findings } + der, err := hex.DecodeString(rec.Certificate) if err != nil || len(der) == 0 { - info.ParseError = fmt.Sprintf("Cannot decode certificate bytes: %v", err) - return info - } - if len(der) > maxKeyMaterialBytes { - info.ParseError = fmt.Sprintf("SMIMEA payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes) - return info + findings = append(findings, Finding{ + Code: CodeSMIMEACertParseError, + Severity: SeverityCrit, + Message: fmt.Sprintf("Cannot decode certificate bytes: %v", err), + Fix: "Re-export the certificate as DER and hex-encode it into the SMIMEA RDATA.", + }) + return info, findings } // Selector 1 carries only a SubjectPublicKeyInfo; parse it that way. if rec.Selector == 1 { - info.PublicKey = analyzeSPKI(der, info) - return info + info.PublicKey = analyzeSPKI(der, &findings) + return info, findings } cert, err := x509.ParseCertificate(der) @@ -472,8 +646,13 @@ func analyzeSMIMEA(body serviceBody) *SMIMEAInfo { if err == nil { err = fmt.Errorf("no certificate found") } - info.ParseError = fmt.Sprintf("Cannot parse X.509 certificate: %v", err) - return info + findings = append(findings, Finding{ + Code: CodeSMIMEACertParseError, + Severity: SeverityCrit, + Message: fmt.Sprintf("Cannot parse X.509 certificate: %v", err), + Fix: "Ensure the certificate is DER-encoded (not PEM) before hex-encoding it into SMIMEA RDATA.", + }) + return info, findings } ci := &CertInfo{ @@ -503,8 +682,98 @@ func analyzeSMIMEA(body serviceBody) *SMIMEAInfo { ci.HasKeyEncipherment = true } - // Email-address / username pairing fact. - if body.Username != "" && len(cert.EmailAddresses) > 0 { + info.Certificate = ci + + now := time.Now() + if now.Before(cert.NotBefore) { + findings = append(findings, Finding{ + Code: CodeSMIMEACertNotYetValid, + Severity: SeverityCrit, + Message: fmt.Sprintf("Certificate is not yet valid (NotBefore = %s).", cert.NotBefore.Format(time.RFC3339)), + Fix: "Check the system clock on the CA/signer, or wait until the certificate's notBefore date.", + }) + } + if now.After(cert.NotAfter) { + findings = append(findings, Finding{ + Code: CodeSMIMEACertExpired, + Severity: SeverityCrit, + Message: fmt.Sprintf("Certificate expired on %s.", cert.NotAfter.Format(time.RFC3339)), + Fix: "Issue a fresh certificate and republish the SMIMEA record.", + }) + } else if expiryWarn > 0 && cert.NotAfter.Sub(now) < expiryWarn { + findings = append(findings, Finding{ + Code: CodeSMIMEACertExpiringSoon, + Severity: SeverityWarn, + Message: fmt.Sprintf("Certificate expires on %s.", cert.NotAfter.Format(time.RFC3339)), + Fix: "Renew before expiry and update the SMIMEA record with the new certificate.", + }) + } + + if !ci.HasEmailProtectionEKU { + sev := SeverityWarn + if requireEmailProtection { + sev = SeverityCrit + } + findings = append(findings, Finding{ + Code: CodeSMIMEANoEmailProtection, + Severity: sev, + Message: "Certificate lacks the emailProtection Extended Key Usage; RFC 8550/8551 agents will refuse it.", + Fix: "Re-issue the certificate with `extendedKeyUsage = emailProtection` (OID 1.3.6.1.5.5.7.3.4).", + }) + } + if !ci.HasDigitalSignature && !ci.HasKeyEncipherment { + findings = append(findings, Finding{ + Code: CodeSMIMEANoKeyUsage, + Severity: SeverityWarn, + Message: "Certificate has neither digitalSignature nor keyEncipherment key usage; S/MIME signing or encryption will be refused.", + Fix: "Add `keyUsage = digitalSignature, keyEncipherment` to the certificate profile.", + }) + } + + // Weak signature algorithms (MD5/SHA-1 based signatures). + switch cert.SignatureAlgorithm { + case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA, + x509.DSAWithSHA1, x509.ECDSAWithSHA1: + findings = append(findings, Finding{ + Code: CodeSMIMEAWeakSignatureAlg, + Severity: SeverityCrit, + Message: fmt.Sprintf("Certificate is signed with %s, a deprecated algorithm.", cert.SignatureAlgorithm), + Fix: "Re-issue the certificate with SHA-256 (or better) signatures.", + }) + } + + // Weak key sizes. + if _, isRSA := cert.PublicKey.(*rsa.PublicKey); isRSA && ci.PublicKeyBits > 0 { + if ci.PublicKeyBits < 2048 { + findings = append(findings, Finding{ + Code: CodeSMIMEAWeakKeySize, + Severity: SeverityCrit, + Message: fmt.Sprintf("RSA key is %d bits (below the 2048-bit minimum).", ci.PublicKeyBits), + Fix: "Re-issue the certificate with an RSA-3072 or ECDSA-P256 key.", + }) + } else if ci.PublicKeyBits < 3072 { + findings = append(findings, Finding{ + Code: CodeSMIMEAWeakKeySize, + Severity: SeverityWarn, + Message: fmt.Sprintf("RSA key is %d bits; prefer 3072+ for new deployments.", ci.PublicKeyBits), + Fix: "Plan a rotation to RSA-3072+ or ECDSA at the next certificate renewal.", + }) + } + } + + // Self-signed certificate flagged for EE usages. For TA usages (0/2) + // self-signed is expected. + if ci.IsSelfSigned && (rec.Usage == 1 || rec.Usage == 3) { + findings = append(findings, Finding{ + Code: CodeSMIMEASelfSigned, + Severity: SeverityInfo, + Message: "End-entity usage advertises a self-signed certificate; DANE-EE (usage 3) makes this safe, but PKIX-EE (usage 1) consumers will reject it.", + Fix: "Switch the record to usage 3 (DANE-EE) if you operate your own CA, or chain the certificate under a public CA for usage 1.", + }) + } + + // Email-address / username pairing. + if body.Username != "" { wantPrefix := strings.ToLower(body.Username) + "@" matched := false for _, e := range cert.EmailAddresses { @@ -513,31 +782,42 @@ func analyzeSMIMEA(body serviceBody) *SMIMEAInfo { break } } - ci.EmailMatchesUsername = &matched + if !matched && len(cert.EmailAddresses) > 0 { + findings = append(findings, Finding{ + Code: CodeSMIMEAEmailMismatch, + Severity: SeverityInfo, + Message: fmt.Sprintf("None of the certificate's email SANs (%s) begin with %s; clients that strictly match SAN to envelope address will reject it.", strings.Join(cert.EmailAddresses, ", "), body.Username+"@"), + Fix: "Re-issue the certificate with the correct `subjectAltName = email:@`.", + }) + } } - info.Certificate = ci - return info + return info, findings } -func analyzeSPKI(der []byte, info *SMIMEAInfo) *PubKeyInfo { +func analyzeSPKI(der []byte, findings *[]Finding) *PubKeyInfo { pub, err := x509.ParsePKIXPublicKey(der) if err != nil { - info.ParseError = fmt.Sprintf("Cannot parse SubjectPublicKeyInfo: %v", err) + *findings = append(*findings, Finding{ + Code: CodeSMIMEACertParseError, + Severity: SeverityCrit, + Message: fmt.Sprintf("Cannot parse SubjectPublicKeyInfo: %v", err), + Fix: "Ensure the SMIMEA selector=1 record carries a DER-encoded SPKI (not a full certificate).", + }) return nil } - pk := &PubKeyInfo{Bits: x509PublicKeyBits(pub)} + info := &PubKeyInfo{Bits: x509PublicKeyBits(pub)} switch pub.(type) { case *rsa.PublicKey: - pk.Algorithm = "RSA" + info.Algorithm = "RSA" case *ecdsa.PublicKey: - pk.Algorithm = "ECDSA" + info.Algorithm = "ECDSA" case ed25519.PublicKey: - pk.Algorithm = "Ed25519" + info.Algorithm = "Ed25519" default: - pk.Algorithm = fmt.Sprintf("%T", pub) + info.Algorithm = fmt.Sprintf("%T", pub) } - return pk + return info } func x509PublicKeyBits(pub any) int { diff --git a/checker/collect_test.go b/checker/collect_test.go deleted file mode 100644 index 1ea3ac1..0000000 --- a/checker/collect_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package checker - -import ( - "encoding/base64" - "encoding/hex" - "strings" - "testing" - - "github.com/miekg/dns" -) - -func TestOwnerHashHex(t *testing.T) { - // RFC 7929 worked example: the SHA-256 of "hugh" truncated to 28 - // bytes, hex-encoded. - got := ownerHashHex("hugh") - if len(got) != 56 { - t.Fatalf("len = %d, want 56", len(got)) - } - if got != strings.ToLower(got) { - t.Errorf("expected lowercase hex, got %q", got) - } - // Stable across calls. - if ownerHashHex("hugh") != got { - t.Error("ownerHashHex is not deterministic") - } - // Different inputs ⇒ different output. - if ownerHashHex("alice") == got { - t.Error("collisions across distinct inputs") - } -} - -func TestExtractOwnerPrefix(t *testing.T) { - cases := []struct { - owner, prefix, want string - }{ - {"abc123._openpgpkey.example.com.", "_openpgpkey", "abc123"}, - {"ABC123._OPENPGPKEY.example.com", "_openpgpkey", "abc123"}, - {"abc123._smimecert.example.com.", "_smimecert", "abc123"}, - {"example.com.", "_openpgpkey", ""}, - {"_openpgpkey.example.com.", "_openpgpkey", ""}, // no leading hash label - {"", "_openpgpkey", ""}, - } - for _, c := range cases { - got := extractOwnerPrefix(c.owner, c.prefix, "") - if got != c.want { - t.Errorf("extractOwnerPrefix(%q,%q) = %q, want %q", c.owner, c.prefix, got, c.want) - } - } -} - -func TestFirstNonEmpty(t *testing.T) { - if got := firstNonEmpty("", " ", "x", "y"); got != "x" { - t.Errorf("got %q, want x", got) - } - if got := firstNonEmpty("", "", ""); got != "" { - t.Errorf("got %q, want empty", got) - } -} - -func TestKindForServiceType(t *testing.T) { - cases := map[string]string{ - ServiceOpenPGP: KindOpenPGPKey, - ServiceSMimeCert: KindSMIMEA, - "abstract.Other": "", - "": "", - } - for in, want := range cases { - if got := kindForServiceType(in); got != want { - t.Errorf("kindForServiceType(%q) = %q, want %q", in, got, want) - } - } -} - -func TestComputeOwner(t *testing.T) { - body := serviceBody{Username: "alice"} - exp, rec := computeOwner(body, OpenPGPKeyPrefix, "example.com") - wantPrefix := ownerHashHex("alice") + "._openpgpkey.example.com." - if exp != wantPrefix { - t.Errorf("expected = %q, want %q", exp, wantPrefix) - } - if rec != "" { - t.Errorf("recorded = %q, want empty", rec) - } - - // With a record carrying its own owner. - body.OpenPGP = &dns.OPENPGPKEY{Hdr: dns.RR_Header{Name: "abc._openpgpkey.example.com."}} - _, rec = computeOwner(body, OpenPGPKeyPrefix, "example.com") - if rec != "abc._openpgpkey.example.com." { - t.Errorf("recorded = %q", rec) - } - - // Empty username yields empty expected owner. - exp, _ = computeOwner(serviceBody{}, OpenPGPKeyPrefix, "example.com") - if exp != "" { - t.Errorf("expected = %q, want empty", exp) - } -} - -func TestAnyOpenPGPMatches(t *testing.T) { - ref := &dns.OPENPGPKEY{PublicKey: "AAAA"} - rrs := []dns.RR{ - &dns.OPENPGPKEY{PublicKey: "BBBB"}, - &dns.OPENPGPKEY{PublicKey: " AAAA "}, // trims whitespace - } - if !anyOpenPGPMatches(rrs, ref) { - t.Error("expected match") - } - if anyOpenPGPMatches([]dns.RR{&dns.OPENPGPKEY{PublicKey: "ZZZZ"}}, ref) { - t.Error("unexpected match") - } - // Non-OPENPGPKEY RRs are skipped silently. - if anyOpenPGPMatches([]dns.RR{&dns.A{}}, ref) { - t.Error("non-OPENPGPKEY RR matched") - } -} - -func TestAnySMIMEAMatches(t *testing.T) { - ref := &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "DEADBEEF"} - rrs := []dns.RR{ - &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "deadbeef"}, - } - if !anySMIMEAMatches(rrs, ref) { - t.Error("expected case-insensitive match") - } - rrs = []dns.RR{&dns.SMIMEA{Usage: 1, Selector: 0, MatchingType: 0, Certificate: "deadbeef"}} - if anySMIMEAMatches(rrs, ref) { - t.Error("usage mismatch should not match") - } -} - -func TestAnalyzeOpenPGP_NoRecord(t *testing.T) { - got := analyzeOpenPGP(serviceBody{}) - if got == nil || got.ParseError == "" { - t.Fatalf("expected ParseError, got %+v", got) - } -} - -func TestAnalyzeOpenPGP_BadBase64(t *testing.T) { - body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: "!!! not base64 !!!"}} - got := analyzeOpenPGP(body) - if !strings.Contains(got.ParseError, "invalid base64") { - t.Errorf("ParseError = %q", got.ParseError) - } -} - -func TestAnalyzeOpenPGP_OversizePayload(t *testing.T) { - // A base64 payload whose decoded size would exceed the cap. - raw := make([]byte, maxKeyMaterialBytes+1024) - body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: base64.StdEncoding.EncodeToString(raw)}} - got := analyzeOpenPGP(body) - if !strings.Contains(got.ParseError, "exceeds") { - t.Errorf("expected size-limit ParseError, got %q", got.ParseError) - } - // And we never tried to actually parse it as a keyring. - if got.EntityCount != 0 { - t.Errorf("EntityCount = %d, want 0", got.EntityCount) - } -} - -func TestAnalyzeOpenPGP_GarbageBytes(t *testing.T) { - // Valid base64, but not a valid OpenPGP packet stream. - body := serviceBody{OpenPGP: &dns.OPENPGPKEY{PublicKey: base64.StdEncoding.EncodeToString([]byte("not a key"))}} - got := analyzeOpenPGP(body) - if got.ParseError == "" { - t.Error("expected ParseError for garbage payload") - } - if got.RawSize == 0 { - t.Error("RawSize should be set even on parse failure") - } -} - -func TestAnalyzeSMIMEA_NoRecord(t *testing.T) { - got := analyzeSMIMEA(serviceBody{}) - if got == nil || got.ParseError == "" { - t.Fatalf("expected ParseError, got %+v", got) - } -} - -func TestAnalyzeSMIMEA_DigestOnly(t *testing.T) { - body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 1, Certificate: "abcd"}} - got := analyzeSMIMEA(body) - if got.ParseError != "" { - t.Errorf("digest-only should not error: %q", got.ParseError) - } - if got.Certificate != nil || got.PublicKey != nil { - t.Error("digest-only should not populate Certificate/PublicKey") - } - if got.HashHex != "abcd" { - t.Errorf("HashHex = %q", got.HashHex) - } -} - -func TestAnalyzeSMIMEA_BadHex(t *testing.T) { - body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: "ZZZZ"}} - got := analyzeSMIMEA(body) - if got.ParseError == "" { - t.Error("expected ParseError for invalid hex") - } -} - -func TestAnalyzeSMIMEA_OversizePayload(t *testing.T) { - huge := strings.Repeat("ab", maxKeyMaterialBytes+1024) - body := serviceBody{SMIMEA: &dns.SMIMEA{Usage: 3, Selector: 0, MatchingType: 0, Certificate: huge}} - got := analyzeSMIMEA(body) - if !strings.Contains(got.ParseError, "exceeds") { - t.Errorf("expected size-limit ParseError, got %q", got.ParseError) - } -} - -func TestAnalyzeSMIMEA_NotACertificate(t *testing.T) { - body := serviceBody{SMIMEA: &dns.SMIMEA{ - Usage: 3, Selector: 0, MatchingType: 0, - Certificate: hex.EncodeToString([]byte("not a DER cert")), - }} - got := analyzeSMIMEA(body) - if got.ParseError == "" { - t.Error("expected ParseError for non-cert bytes") - } -} diff --git a/checker/definition.go b/checker/definition.go index 06f4e7b..12325c4 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -19,9 +19,9 @@ const ( ) // Definition is the package-level helper returned to the host by the -// plugin entrypoint and used by server.New via the provider's +// plugin entrypoint and used by sdk.NewServer via the provider's // CheckerDefinitionProvider implementation. -func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition { +func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "openpgpkey-smimea", Name: "OPENPGPKEY & SMIMEA", @@ -43,6 +43,27 @@ func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition { Placeholder: "1.1.1.1", Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.", }, + { + Id: OptionCertExpiryWarnDays, + Type: "number", + Label: "Expiry warning threshold (days)", + Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.", + Default: float64(30), + }, + { + Id: OptionRequireDNSSEC, + Type: "bool", + Label: "Require DNSSEC", + Description: "When enabled, a non-DNSSEC-validated lookup is reported as critical (otherwise as warning). RFC 7929 and RFC 8162 mandate DNSSEC.", + Default: true, + }, + { + Id: OptionRequireEmailProtection, + Type: "bool", + Label: "Require emailProtection EKU", + Description: "When enabled, an S/MIME certificate without the emailProtection Extended Key Usage is reported as critical.", + Default: true, + }, }, DomainOpts: []sdk.CheckerOptionDocumentation{ { @@ -70,7 +91,7 @@ func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition { }, }, }, - Rules: Rules(), + Rules: []sdk.CheckRule{Rule()}, Interval: &sdk.CheckIntervalSpec{ Min: 1 * time.Hour, Max: 7 * 24 * time.Hour, diff --git a/checker/dns.go b/checker/dns.go index 07e0f08..70f476d 100644 --- a/checker/dns.go +++ b/checker/dns.go @@ -13,13 +13,6 @@ import ( // dnsTimeout is the per-query deadline used by every helper here. const dnsTimeout = 5 * time.Second -// maxAnswerRecords caps how many answer RRs of the requested type are -// retained from a single DNS response. A DANE owner serving more than a -// handful of keys is already abnormal; bounding the count keeps later -// per-record work (parsing, comparison) from blowing up if a zone (or a -// hostile resolver) returns a pathological answer set. -const maxAnswerRecords = 64 - // dnsLookupAnswer is the subset of a DNS answer this checker cares about. type dnsLookupAnswer struct { // Records are the answer records of the requested type. @@ -97,13 +90,9 @@ func lookup(ctx context.Context, servers []string, owner string, qtype uint16) ( Server: srv, } for _, rr := range in.Answer { - if rr.Header().Rrtype != qtype { - continue + if rr.Header().Rrtype == qtype { + ans.Records = append(ans.Records, rr) } - if len(ans.Records) >= maxAnswerRecords { - break - } - ans.Records = append(ans.Records, rr) } if in.Rcode == dns.RcodeSuccess || in.Rcode == dns.RcodeNameError { return ans, nil diff --git a/checker/dns_test.go b/checker/dns_test.go deleted file mode 100644 index 763830a..0000000 --- a/checker/dns_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package checker - -import ( - "reflect" - "testing" -) - -func TestJoinSubdomain(t *testing.T) { - cases := []struct { - sub, origin, want string - }{ - {"", "example.com", "example.com."}, - {"@", "example.com", "example.com."}, - {"www", "example.com", "www.example.com."}, - {"www.", "example.com.", "www.example.com."}, - {"www.example.com", "example.com", "www.example.com."}, - {"example.com", "example.com", "example.com."}, - } - for _, c := range cases { - got := joinSubdomain(c.sub, c.origin) - if got != c.want { - t.Errorf("joinSubdomain(%q,%q) = %q, want %q", c.sub, c.origin, got, c.want) - } - } -} - -func TestResolvers_Explicit(t *testing.T) { - got := resolvers("1.1.1.1, 8.8.8.8:5353 ,") - want := []string{"1.1.1.1:53", "8.8.8.8:5353"} - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v, want %v", got, want) - } -} - -func TestResolvers_FallbackList(t *testing.T) { - // We don't trust /etc/resolv.conf to be absent in all CI environments, - // but the empty-input path must always return at least one resolver. - got := resolvers("") - if len(got) == 0 { - t.Fatal("expected at least one resolver") - } -} - -func TestMaxAnswerRecords_Constant(t *testing.T) { - // Sanity check: don't silently lower the cap to something useless - // without updating tests / behaviour. - if maxAnswerRecords < 8 { - t.Errorf("maxAnswerRecords=%d is suspiciously low", maxAnswerRecords) - } -} diff --git a/checker/interactive.go b/checker/interactive.go index a69039a..45e1693 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,5 +1,3 @@ -//go:build standalone - package checker import ( @@ -7,16 +5,14 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "strings" "github.com/miekg/dns" sdk "git.happydns.org/checker-sdk-go/checker" - "git.happydns.org/checker-sdk-go/checker/server" ) -// RenderForm implements server.Interactive. It exposes the minimal +// RenderForm implements sdk.CheckerInteractive. It exposes the minimal // inputs needed to bootstrap a standalone OPENPGPKEY/SMIMEA check: an // email address (the local part is hashed into the owner name) and a // kind selector. The DNS resolver and severity-tuning options mirror @@ -67,7 +63,7 @@ func (p *emailKeyProvider) RenderForm() []sdk.CheckerOptionField { } } -// ParseForm implements server.Interactive. It validates the inputs, +// ParseForm implements sdk.CheckerInteractive. It validates the inputs, // resolves the DNS record matching the requested kind, and returns the // CheckerOptions that Collect expects, including a synthesised service // envelope built from the live DNS answer. @@ -106,7 +102,7 @@ func (p *emailKeyProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error } resolverOpt := strings.TrimSpace(r.FormValue(OptionResolver)) - owner := dns.Fqdn(ownerHashHex(username) + "." + prefix + "." + domain) + owner := dns.Fqdn(ownerHashHex(username) + "." + strings.TrimPrefix(prefix, ".") + "." + domain) ctx, cancel := context.WithTimeout(r.Context(), dnsTimeout*3) defer cancel() @@ -164,12 +160,12 @@ func (p *emailKeyProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error // parseFloatOr parses a decimal string, returning fallback on error. func parseFloatOr(s string, fallback float64) float64 { - f, err := strconv.ParseFloat(s, 64) - if err != nil { + var f float64 + if _, err := fmt.Sscanf(s, "%f", &f); err != nil { return fallback } return f } // Compile-time assertion that the provider implements the optional interface. -var _ server.Interactive = (*emailKeyProvider)(nil) +var _ sdk.CheckerInteractive = (*emailKeyProvider)(nil) diff --git a/checker/provider.go b/checker/provider.go index cf9e3e6..94bcd1a 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -15,3 +15,9 @@ type emailKeyProvider struct{} func (p *emailKeyProvider) Key() sdk.ObservationKey { return ObservationKey } + +// Definition implements sdk.CheckerDefinitionProvider so the SDK server +// can expose /definition without an extra argument. +func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} diff --git a/checker/report.go b/checker/report.go index c6cc7fd..71e9c95 100644 --- a/checker/report.go +++ b/checker/report.go @@ -57,7 +57,6 @@ type reportData struct { Remediations []remediation Findings []findingRow - HasStates bool // true when rule states were threaded; gates the Findings section CritCount int WarnCount int InfoCount int @@ -108,7 +107,7 @@ func (p *emailKeyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) return "", fmt.Errorf("unmarshal report data: %w", err) } - rd := buildReportData(&data, ctx.States()) + rd := buildReportData(&data) var buf strings.Builder if err := reportTemplate.Execute(&buf, rd); err != nil { @@ -117,7 +116,7 @@ func (p *emailKeyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) return buf.String(), nil } -func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData { +func buildReportData(d *EmailKeyData) reportData { rd := reportData{ Kind: d.Kind, QueriedOwner: d.QueriedOwner, @@ -137,52 +136,22 @@ func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData { rd.DNSSEC = "insecure" } - if d.Kind == KindOpenPGPKey && d.OpenPGP != nil { - rd.OpenPGP = buildOpenPGPView(d.OpenPGP) - } - if d.Kind == KindSMIMEA && d.SMIMEA != nil { - rd.SMIMEA = buildSMIMEAView(d.SMIMEA) - } - - // No rule states threaded through: data-only view. - if len(states) == 0 { - rd.Badge = "neutral" - rd.Headline = "Record details" - return rd - } - rd.HasStates = true - - // Pick the states we want on screen: drop bare StatusOK, and drop - // StatusInfo with no message (non-applicable rules). Keep anything - // else. - kept := make([]sdk.CheckState, 0, len(states)) - for _, s := range states { - if s.Status == sdk.StatusOK { - continue - } - if s.Status == sdk.StatusInfo && strings.TrimSpace(s.Message) == "" { - continue - } - kept = append(kept, s) - } - - // Sort by severity (crit first). - sort.SliceStable(kept, func(i, j int) bool { - return statusRank(kept[i].Status) > statusRank(kept[j].Status) + // Sort findings by severity (crit first) for stable display. + findings := make([]Finding, len(d.Findings)) + copy(findings, d.Findings) + sort.SliceStable(findings, func(i, j int) bool { + return severityRank(findings[i].Severity) > severityRank(findings[j].Severity) }) - for _, s := range kept { + for _, f := range findings { rd.Findings = append(rd.Findings, findingRow{ - Code: s.Code, - Severity: severityLabel(s.Status), - Message: s.Message, - Fix: stateHint(s), + Code: f.Code, Severity: string(f.Severity), Message: f.Message, Fix: f.Fix, }) - switch s.Status { - case sdk.StatusCrit, sdk.StatusError: + switch f.Severity { + case SeverityCrit: rd.CritCount++ - case sdk.StatusWarn: + case SeverityWarn: rd.WarnCount++ - case sdk.StatusInfo: + case SeverityInfo: rd.InfoCount++ } } @@ -202,45 +171,18 @@ func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData { rd.Headline = "All checks passed" } - rd.Remediations = buildRemediations(d, kept) + if d.Kind == KindOpenPGPKey && d.OpenPGP != nil { + rd.OpenPGP = buildOpenPGPView(d.OpenPGP) + } + if d.Kind == KindSMIMEA && d.SMIMEA != nil { + rd.SMIMEA = buildSMIMEAView(d.SMIMEA) + } + + rd.Remediations = buildRemediations(d) return rd } -func stateHint(s sdk.CheckState) string { - if s.Meta == nil { - return "" - } - if v, ok := s.Meta["hint"].(string); ok { - return v - } - return "" -} - -func severityLabel(st sdk.Status) string { - switch st { - case sdk.StatusCrit, sdk.StatusError: - return "crit" - case sdk.StatusWarn: - return "warn" - case sdk.StatusInfo: - return "info" - } - return "info" -} - -func statusRank(st sdk.Status) int { - switch st { - case sdk.StatusCrit, sdk.StatusError: - return 3 - case sdk.StatusWarn: - return 2 - case sdk.StatusInfo: - return 1 - } - return 0 -} - func buildOpenPGPView(o *OpenPGPInfo) *openPGPView { v := &openPGPView{ Fingerprint: formatFingerprint(o.Fingerprint), @@ -306,25 +248,28 @@ func buildSMIMEAView(s *SMIMEAInfo) *smimeaView { return v } -// buildRemediations surfaces a focused, user-actionable card for each -// of the most common failure scenarios present in `states`. Only rules -// with a matching state produce a remediation; a clean run shows none. -func buildRemediations(d *EmailKeyData, states []sdk.CheckState) []remediation { +// buildRemediations detects the most common failure scenarios and +// surfaces a focused, user-actionable card for each. Only matching +// issues produce a remediation; a clean run shows none. +func buildRemediations(d *EmailKeyData) []remediation { var out []remediation - byCode := map[string]bool{} - for _, s := range states { - byCode[s.Code] = true + byCode := map[string]Finding{} + for _, f := range d.Findings { + // Keep the first (most severe after sort) finding per code. + if _, ok := byCode[f.Code]; !ok { + byCode[f.Code] = f + } } pick := func(code, title, body string) { - if !byCode[code] { + if _, ok := byCode[code]; !ok { return } out = append(out, remediation{Title: title, Body: template.HTML(body)}) } - pick(RuleDNSNoRecord, + pick(CodeDNSNoRecord, "Publish the record in DNS", fmt.Sprintf(`No %s record resolves at %s. Publish it in the zone and reload the authoritative servers.

Quick checklist: @@ -340,7 +285,7 @@ Quick checklist: kindRRType(d.Kind), template.HTMLEscapeString(d.QueriedOwner))) - pick(RuleDNSSECNotValidated, + pick(CodeDNSNotSecure, "Enable DNSSEC on the zone", `RFC 7929 and RFC 8162 only grant authority to the key/certificate when DNSSEC validates it. Without DNSSEC, an attacker on the network path can substitute the RR with their own material and impersonate the user.

Steps: @@ -350,14 +295,14 @@ Steps:
  • Re-run this checker; the AD flag should light up.
  • `) - pick(RuleOwnerHashMismatch, + pick(CodeOwnerHashMismatch, "Fix the record's owner-name hash", `The record is published at a name whose first label does not equal hex(sha256(localpart))[:56] (28 bytes). Email agents will never find it because they compute the hash from the recipient address.

    Compute the correct name:
    printf '%s' "local-part" | openssl dgst -sha256 | cut -c 1-56 | tr -d '\n' ; echo "._openpgpkey.domain.tld"
    Then republish the record at that owner name.`) - pick(RulePGPPrimaryExpired, + pick(CodePGPExpired, "Renew the expired OpenPGP key", `The primary key's self-signature expired, so clients will refuse to encrypt to it.
    gpg --edit-key <fingerprint>
    @@ -367,12 +312,12 @@ gpg> save
     gpg --export <fingerprint> | base64
    Paste the resulting base64 back into the OPENPGPKEY record.`) - pick(RulePGPPrimaryRevoked, + pick(CodePGPRevoked, "Publish a fresh, non-revoked key", `The record carries a revoked primary key; clients will stop encrypting mail to this address as soon as they process the revocation.

    Either generate a new key pair and publish it here, or remove the OPENPGPKEY record so senders fall back to regular email (unencrypted).`) - pick(RulePGPNoEncryption, + pick(CodePGPNoEncryption, "Add an encryption subkey", `Every non-revoked key in the record is marked sign-only. Mail clients will refuse to encrypt to this record.
    gpg --edit-key <fingerprint>
    @@ -381,14 +326,14 @@ gpg> addkey
     gpg> save
    Re-export and republish.`) - pick(RulePGPWeakKeySize, + pick(CodePGPWeakKeySize, "Rotate away from weak RSA keys", `RSA below 2048 bits is considered broken. Generate a modern key and republish:
    gpg --full-generate-key
     # choose 1 (RSA+RSA) with 3072/4096 bits,
     # or 9 (ECC+ECC) for Curve25519.
    `) - pick(RuleSMIMEACertExpired, + pick(CodeSMIMEACertExpired, "Renew the S/MIME certificate", `The certificate expired. Issue a fresh one and update the SMIMEA record:
    openssl req -new -key user.key -subj "/emailAddress=user@example.org" -out user.csr
    @@ -396,7 +341,7 @@ Re-export and republish.`)
     openssl x509 -in user.crt -outform DER | xxd -p -c256 > smimea.hex
    Splice the hex payload into the SMIMEA RDATA.`) - pick(RuleSMIMEANoEmailProtect, + pick(CodeSMIMEANoEmailProtection, "Add the emailProtection EKU", `Conforming S/MIME agents (RFC 8550/8551) only accept certificates whose Extended Key Usage advertises email protection (OID 1.3.6.1.5.5.7.3.4).

    In your openssl.cnf:
    @@ -405,23 +350,35 @@ extendedKeyUsage = emailProtection keyUsage = digitalSignature, keyEncipherment Re-issue the certificate, then update the SMIMEA record.`) - pick(RuleSMIMEAWeakSigAlgorithm, + pick(CodeSMIMEAWeakSignatureAlg, "Re-issue with a strong signature algorithm", `MD5 and SHA-1 based signatures are collision-vulnerable and will be rejected by modern mail agents.

    Use at least SHA-256 when issuing:
    openssl x509 -req -sha256 -in user.csr -CA ca.pem -CAkey ca.key -out user.crt
    `) - pick(RuleSMIMEABadUsage, + pick(CodeSMIMEABadUsage, "Pick a valid SMIMEA usage", `SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA) or 3 (DANE-EE). For self-hosted end-entity certificates, 3 (DANE-EE) is the right choice: it tells verifiers the record carries the exact certificate to trust and no chain validation is required.`) - pick(RuleSMIMEAHashOnly, + pick(CodeSMIMEAHashOnly, "Consider publishing the full certificate", `Matching types 1 (SHA-256) and 2 (SHA-512) only transport a digest. Consumers cannot extract the certificate from DNS and must obtain it through a side channel. Matching type 0 (Full) avoids that round trip and is the most interoperable option.`) return out } +func severityRank(s Severity) int { + switch s { + case SeverityCrit: + return 3 + case SeverityWarn: + return 2 + case SeverityInfo: + return 1 + } + return 0 +} + func smimeaUsageName(u uint8) string { switch u { case 0: @@ -692,7 +649,6 @@ td.sev-info { color: #1e40af; font-weight: 600; } {{end}}
    - {{if .HasStates}}

    Findings {{if .CritCount}}{{.CritCount}} crit{{end}} {{if .WarnCount}}{{.WarnCount}} warn{{end}} {{if .InfoCount}}{{.InfoCount}} info{{end}}

    @@ -711,7 +667,6 @@ td.sev-info { color: #1e40af; font-weight: 600; } {{else}}

    No issues detected.

    {{end}} - {{end}}

    Collected at {{.CollectedAt}}

    diff --git a/checker/rule.go b/checker/rule.go index 0aa51b6..c0a73d1 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -7,36 +7,22 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// issue is a rule-internal description of a failed test. Rules return a -// slice of issues from their check func; Evaluate converts them to -// sdk.CheckState. -type issue struct { - Severity sdk.Status // StatusInfo / StatusWarn / StatusCrit - Message string - Hint string // remediation hint; surfaced as Meta["hint"] - Subject string // optional; overrides default data.QueriedOwner +// Rule returns the single aggregation rule for this checker. It folds +// every finding produced by Collect into a CheckState whose status is +// the worst severity seen. +func Rule() sdk.CheckRule { + return &emailKeyRule{} } -// ruleFunc consumes the facts + runtime options and returns zero or more -// issues. No issues means the test passed. -type ruleFunc func(d *EmailKeyData, opts sdk.CheckerOptions) []issue +type emailKeyRule struct{} -// rule is a data-driven CheckRule. All per-test rules share this type; -// only name / description / applicable kinds / options / check differ. -type rule struct { - name string - description string - okMessage string // message for StatusOK returns - kinds []string // applicable kinds; empty = both - options sdk.CheckerOptionsDocumentation // per-rule options - check ruleFunc +func (r *emailKeyRule) Name() string { return "openpgpkey_smimea_check" } + +func (r *emailKeyRule) Description() string { + return "Validates a DNS-published OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162), running DNSSEC, record-hash, parse, expiration, algorithm-strength, and S/MIME EKU checks." } -func (r *rule) Name() string { return r.name } -func (r *rule) Description() string { return r.description } -func (r *rule) Options() sdk.CheckerOptionsDocumentation { return r.options } - -func (r *rule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { +func (r *emailKeyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var data EmailKeyData if err := obs.Get(ctx, ObservationKey, &data); err != nil { return []sdk.CheckState{{ @@ -45,65 +31,84 @@ func (r *rule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk Code: "openpgpkey_observation_error", }} } - - if len(r.kinds) > 0 && !containsString(r.kinds, data.Kind) { - return []sdk.CheckState{{ - Status: sdk.StatusUnknown, - Message: fmt.Sprintf("Not applicable for %s records.", data.Kind), - Code: r.name, - Subject: data.QueriedOwner, - }} - } - - issues := r.check(&data, opts) - if len(issues) == 0 { - msg := r.okMessage - if msg == "" { - msg = "Check passed." - } - return []sdk.CheckState{{ - Status: sdk.StatusOK, - Message: msg, - Code: r.name, - Subject: data.QueriedOwner, - }} - } - - states := make([]sdk.CheckState, 0, len(issues)) - for _, iss := range issues { - subject := iss.Subject - if subject == "" { - subject = data.QueriedOwner - } - var meta map[string]any - if iss.Hint != "" { - meta = map[string]any{"hint": iss.Hint} - } - states = append(states, sdk.CheckState{ - Status: iss.Severity, - Message: iss.Message, - Code: r.name, - Subject: subject, - Meta: meta, - }) - } - return states + return []sdk.CheckState{evaluate(&data)} } -// Rules returns the full set of per-test rules for this checker. -func Rules() []sdk.CheckRule { - out := make([]sdk.CheckRule, len(allRules)) - for i := range allRules { - out[i] = allRules[i] - } - return out -} - -func containsString(hay []string, needle string) bool { - for _, v := range hay { - if v == needle { - return true +// evaluate folds findings into a CheckState. The status is the highest +// severity observed: any Crit makes the whole result Crit, any Warn +// makes it Warn, otherwise Info/OK. +func evaluate(data *EmailKeyData) sdk.CheckState { + var crit, warn, info int + var firstCrit, firstWarn, firstInfo string + for _, f := range data.Findings { + switch f.Severity { + case SeverityCrit: + crit++ + if firstCrit == "" { + firstCrit = f.Message + } + case SeverityWarn: + warn++ + if firstWarn == "" { + firstWarn = f.Message + } + case SeverityInfo: + info++ + if firstInfo == "" { + firstInfo = f.Message + } } } - return false + + status := sdk.StatusOK + msg := summariseHealthy(data) + code := "openpgpkey_ok" + switch { + case crit > 0: + status = sdk.StatusCrit + msg = firstCrit + code = "openpgpkey_crit" + case warn > 0: + status = sdk.StatusWarn + msg = firstWarn + code = "openpgpkey_warn" + case info > 0: + status = sdk.StatusInfo + msg = firstInfo + code = "openpgpkey_info" + } + + meta := map[string]any{ + "kind": data.Kind, + "queried": data.QueriedOwner, + "record_count": data.RecordCount, + "findings": data.Findings, + } + if data.DNSSECSecure != nil { + meta["dnssec_secure"] = *data.DNSSECSecure + } + + return sdk.CheckState{ + Status: status, + Message: msg, + Code: code, + Subject: data.QueriedOwner, + Meta: meta, + } +} + +func summariseHealthy(data *EmailKeyData) string { + switch data.Kind { + case KindOpenPGPKey: + if data.OpenPGP != nil && data.OpenPGP.Fingerprint != "" { + return fmt.Sprintf("OPENPGPKEY %s published and valid (fingerprint %s)", data.QueriedOwner, data.OpenPGP.Fingerprint) + } + return fmt.Sprintf("OPENPGPKEY %s published and valid", data.QueriedOwner) + case KindSMIMEA: + if data.SMIMEA != nil && data.SMIMEA.Certificate != nil { + return fmt.Sprintf("SMIMEA %s valid (subject %s)", data.QueriedOwner, data.SMIMEA.Certificate.Subject) + } + return fmt.Sprintf("SMIMEA %s published and valid", data.QueriedOwner) + } + return "Record validated" } diff --git a/checker/rules_check.go b/checker/rules_check.go deleted file mode 100644 index f0b5beb..0000000 --- a/checker/rules_check.go +++ /dev/null @@ -1,300 +0,0 @@ -package checker - -import ( - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// Rule names. Each name is also the CheckState.Code emitted by the -// corresponding rule. They are kept as exported constants so callers -// (e.g. the report layer's remediation picker) can reference them -// without copying strings. -const ( - RuleDNSQueryFailed = "dns_query_failed" - RuleDNSNoRecord = "dns_no_record" - RuleDNSRecordMismatch = "dns_record_mismatch" - RuleDNSSECNotValidated = "dnssec_not_validated" - RuleOwnerHashMismatch = "owner_hash_mismatch" - - RulePGPParseError = "pgp_parse_error" - RulePGPPrimaryRevoked = "pgp_primary_revoked" - RulePGPPrimaryExpired = "pgp_primary_expired" - RulePGPPrimaryExpiring = "pgp_primary_expiring_soon" - RulePGPWeakAlgorithm = "pgp_weak_algorithm" - RulePGPWeakKeySize = "pgp_weak_key_size" - RulePGPNoEncryption = "pgp_no_encryption_subkey" - RulePGPNoIdentity = "pgp_no_identity" - RulePGPUIDMismatch = "pgp_uid_mismatch" - RulePGPMultipleEntities = "pgp_multiple_entities" - RulePGPRecordTooLarge = "pgp_record_too_large" - - RuleSMIMEABadUsage = "smimea_bad_usage" - RuleSMIMEABadSelector = "smimea_bad_selector" - RuleSMIMEABadMatchType = "smimea_bad_match_type" - RuleSMIMEACertParseError = "smimea_cert_parse_error" - RuleSMIMEACertNotYetValid = "smimea_cert_not_yet_valid" - RuleSMIMEACertExpired = "smimea_cert_expired" - RuleSMIMEACertExpiring = "smimea_cert_expiring_soon" - RuleSMIMEANoEmailProtect = "smimea_no_email_protection_eku" - RuleSMIMEAMissingKeyUsage = "smimea_missing_key_usage" - RuleSMIMEAWeakSigAlgorithm = "smimea_weak_signature_algorithm" - RuleSMIMEAWeakKeySize = "smimea_weak_key_size" - RuleSMIMEASelfSigned = "smimea_self_signed" - RuleSMIMEAEmailMismatch = "smimea_email_mismatch" - RuleSMIMEAHashOnly = "smimea_hash_only" -) - -var kindsOpenPGP = []string{KindOpenPGPKey} -var kindsSMIMEA = []string{KindSMIMEA} - -// optExpiryWarn is the per-rule option documentation for -// OptionCertExpiryWarnDays. The same option id is shared by the PGP -// expiring-soon rule and the SMIMEA expiring-soon rule. -var optExpiryWarn = sdk.CheckerOptionsDocumentation{ - UserOpts: []sdk.CheckerOptionDocumentation{{ - Id: OptionCertExpiryWarnDays, - Type: "number", - Label: "Expiry warning threshold (days)", - Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.", - Default: float64(30), - }}, -} - -var optRequireDNSSEC = sdk.CheckerOptionsDocumentation{ - UserOpts: []sdk.CheckerOptionDocumentation{{ - Id: OptionRequireDNSSEC, - Type: "bool", - Label: "Require DNSSEC", - Description: "When enabled, a non-DNSSEC-validated lookup is reported as critical (otherwise as warning). RFC 7929 and RFC 8162 mandate DNSSEC.", - Default: true, - }}, -} - -var optRequireEmailProtection = sdk.CheckerOptionsDocumentation{ - UserOpts: []sdk.CheckerOptionDocumentation{{ - Id: OptionRequireEmailProtection, - Type: "bool", - Label: "Require emailProtection EKU", - Description: "When enabled, an S/MIME certificate without the emailProtection Extended Key Usage is reported as critical.", - Default: true, - }}, -} - -// allRules is the canonical list of rules this checker exposes. Each -// entry registers one CheckRule, implemented by the check funcs -// in rules_dns.go, rules_pgp.go, and rules_smimea.go. -var allRules = []*rule{ - // ── DNS / owner (both kinds), rules_dns.go ── - { - name: RuleDNSQueryFailed, - description: "The DNS lookup for the OPENPGPKEY/SMIMEA record must succeed.", - okMessage: "DNS lookup succeeded.", - check: checkDNSQueryFailed, - }, - { - name: RuleDNSNoRecord, - description: "An OPENPGPKEY/SMIMEA record must be published at the expected owner name.", - okMessage: "A record is published at the queried owner name.", - check: checkDNSNoRecord, - }, - { - name: RuleDNSRecordMismatch, - description: "The record returned by DNS must match the service-declared record.", - okMessage: "DNS matches the service-declared record.", - check: checkDNSRecordMismatch, - }, - { - name: RuleDNSSECNotValidated, - description: "The record must be authenticated by DNSSEC; RFC 7929 and RFC 8162 mandate it.", - okMessage: "DNSSEC validated the record (AD flag set).", - options: optRequireDNSSEC, - check: checkDNSSECNotValidated, - }, - { - name: RuleOwnerHashMismatch, - description: "The first label of the owner name must equal hex(sha256(username))[:28].", - okMessage: "Owner-name hash matches the username.", - check: checkOwnerHashMismatch, - }, - - // ── OpenPGP (kind openpgpkey), rules_pgp.go ── - { - name: RulePGPParseError, - description: "The OPENPGPKEY record must decode as a valid OpenPGP key.", - okMessage: "OpenPGP key parsed successfully.", - kinds: kindsOpenPGP, - check: checkPGPParseError, - }, - { - name: RulePGPPrimaryRevoked, - description: "The OpenPGP primary key must not carry a revocation signature.", - okMessage: "Primary key is not revoked.", - kinds: kindsOpenPGP, - check: checkPGPPrimaryRevoked, - }, - { - name: RulePGPPrimaryExpired, - description: "The OpenPGP primary key must not be past its self-signature expiry.", - okMessage: "Primary key is not expired.", - kinds: kindsOpenPGP, - check: checkPGPPrimaryExpired, - }, - { - name: RulePGPPrimaryExpiring, - description: "Warn when the OpenPGP primary key expires within the configured window.", - okMessage: "Primary key is not expiring soon.", - kinds: kindsOpenPGP, - options: optExpiryWarn, - check: checkPGPPrimaryExpiring, - }, - { - name: RulePGPWeakAlgorithm, - description: "The OpenPGP keys must not use legacy algorithms (DSA/ElGamal).", - okMessage: "All OpenPGP keys use modern algorithms.", - kinds: kindsOpenPGP, - check: checkPGPWeakAlgorithm, - }, - { - name: RulePGPWeakKeySize, - description: "OpenPGP RSA keys must be at least 2048 bits (NIST SP 800-131A); 3072+ preferred.", - okMessage: "All RSA OpenPGP keys meet the minimum key size.", - kinds: kindsOpenPGP, - check: checkPGPWeakKeySize, - }, - { - name: RulePGPNoEncryption, - description: "At least one active (non-revoked, non-expired) OpenPGP key must advertise encryption capability.", - okMessage: "The entity has an active encryption-capable key.", - kinds: kindsOpenPGP, - check: checkPGPNoEncryption, - }, - { - name: RulePGPNoIdentity, - description: "The OpenPGP key must carry at least one self-signed User ID.", - okMessage: "The OpenPGP key has at least one identity.", - kinds: kindsOpenPGP, - check: checkPGPNoIdentity, - }, - { - name: RulePGPUIDMismatch, - description: "At least one OpenPGP UID should reference .", - okMessage: "At least one UID matches the username.", - kinds: kindsOpenPGP, - check: checkPGPUIDMismatch, - }, - { - name: RulePGPMultipleEntities, - description: "RFC 7929 recommends a single OpenPGP entity per record.", - okMessage: "The record carries a single OpenPGP entity.", - kinds: kindsOpenPGP, - check: checkPGPMultipleEntities, - }, - { - name: RulePGPRecordTooLarge, - description: "The OPENPGPKEY record should stay below 4 KiB to fit typical UDP answers.", - okMessage: "Record size is within the recommended limit.", - kinds: kindsOpenPGP, - check: checkPGPRecordTooLarge, - }, - - // ── SMIMEA (kind smimea), rules_smimea.go ── - { - name: RuleSMIMEABadUsage, - description: "SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA), or 3 (DANE-EE).", - okMessage: "SMIMEA usage is valid.", - kinds: kindsSMIMEA, - check: checkSMIMEABadUsage, - }, - { - name: RuleSMIMEABadSelector, - description: "SMIMEA selector must be 0 (Cert) or 1 (SPKI).", - okMessage: "SMIMEA selector is valid.", - kinds: kindsSMIMEA, - check: checkSMIMEABadSelector, - }, - { - name: RuleSMIMEABadMatchType, - description: "SMIMEA matching type must be 0 (Full), 1 (SHA-256), or 2 (SHA-512).", - okMessage: "SMIMEA matching type is valid.", - kinds: kindsSMIMEA, - check: checkSMIMEABadMatchType, - }, - { - name: RuleSMIMEACertParseError, - description: "The SMIMEA record must decode as a valid X.509 certificate (or SPKI, for selector 1).", - okMessage: "Certificate parsed successfully.", - kinds: kindsSMIMEA, - check: checkSMIMEACertParseError, - }, - { - name: RuleSMIMEACertNotYetValid, - description: "The S/MIME certificate's NotBefore must be in the past.", - okMessage: "Certificate is within its validity window.", - kinds: kindsSMIMEA, - check: checkSMIMEACertNotYetValid, - }, - { - name: RuleSMIMEACertExpired, - description: "The S/MIME certificate's NotAfter must be in the future.", - okMessage: "Certificate is not expired.", - kinds: kindsSMIMEA, - check: checkSMIMEACertExpired, - }, - { - name: RuleSMIMEACertExpiring, - description: "Warn when the S/MIME certificate expires within the configured window.", - okMessage: "Certificate is not expiring soon.", - kinds: kindsSMIMEA, - options: optExpiryWarn, - check: checkSMIMEACertExpiring, - }, - { - name: RuleSMIMEANoEmailProtect, - description: "The S/MIME certificate must advertise the emailProtection Extended Key Usage (RFC 8550/8551).", - okMessage: "Certificate carries emailProtection EKU.", - kinds: kindsSMIMEA, - options: optRequireEmailProtection, - check: checkSMIMEANoEmailProtect, - }, - { - name: RuleSMIMEAMissingKeyUsage, - description: "The S/MIME certificate must carry digitalSignature and/or keyEncipherment key usage.", - okMessage: "Certificate carries the expected key usages.", - kinds: kindsSMIMEA, - check: checkSMIMEAMissingKeyUsage, - }, - { - name: RuleSMIMEAWeakSigAlgorithm, - description: "The certificate must not be signed with a deprecated algorithm (MD2/MD5/SHA-1 based).", - okMessage: "Certificate uses a strong signature algorithm.", - kinds: kindsSMIMEA, - check: checkSMIMEAWeakSigAlgorithm, - }, - { - name: RuleSMIMEAWeakKeySize, - description: "SMIMEA RSA keys must be at least 2048 bits; 3072+ preferred.", - okMessage: "Certificate key size meets the minimum.", - kinds: kindsSMIMEA, - check: checkSMIMEAWeakKeySize, - }, - { - name: RuleSMIMEASelfSigned, - description: "Self-signed certificates with PKIX-EE (usage 1) are rejected by standard clients.", - okMessage: "Certificate chain is appropriate for the declared usage.", - kinds: kindsSMIMEA, - check: checkSMIMEASelfSigned, - }, - { - name: RuleSMIMEAEmailMismatch, - description: "At least one email SAN on the certificate should begin with @.", - okMessage: "At least one email SAN matches the username.", - kinds: kindsSMIMEA, - check: checkSMIMEAEmailMismatch, - }, - { - name: RuleSMIMEAHashOnly, - description: "SMIMEA matching types 1/2 transport only a digest; the certificate cannot be verified.", - okMessage: "Full certificate is published.", - kinds: kindsSMIMEA, - check: checkSMIMEAHashOnly, - }, -} diff --git a/checker/rules_dns.go b/checker/rules_dns.go deleted file mode 100644 index a01b1a2..0000000 --- a/checker/rules_dns.go +++ /dev/null @@ -1,78 +0,0 @@ -package checker - -import ( - "fmt" - "strings" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// DNS-level rules: lookup outcome, record presence, service/DNS parity, -// DNSSEC authentication, and owner-name hash correctness. These apply -// to both OPENPGPKEY and SMIMEA records. - -func checkDNSQueryFailed(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.DNSQueryError == "" { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: d.DNSQueryError, - Hint: "Check that the zone is published at an authoritative server reachable from this checker.", - }} -} - -func checkDNSNoRecord(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.DNSAnswerPresent == nil || *d.DNSAnswerPresent { - return nil - } - kind := "OPENPGPKEY" - if d.Kind == KindSMIMEA { - kind = "SMIMEA" - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: fmt.Sprintf("Authoritative DNS returned no %s record at %s.", kind, d.QueriedOwner), - Hint: "Ensure the record is present in the zone and that the zone has been loaded by the authoritative servers.", - }} -} - -func checkDNSRecordMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.DNSRecordMatchesService == nil || *d.DNSRecordMatchesService { - return nil - } - return []issue{{ - Severity: sdk.StatusWarn, - Message: "The record returned by DNS does not match the one declared in the service. The zone may not have been re-published since the last edit.", - Hint: "Propagate the zone to the authoritative servers, then wait for TTL/negative-cache expiry.", - }} -} - -func checkDNSSECNotValidated(d *EmailKeyData, opts sdk.CheckerOptions) []issue { - if d.DNSSECSecure == nil || *d.DNSSECSecure { - return nil - } - sev := sdk.StatusWarn - if sdk.GetBoolOption(opts, OptionRequireDNSSEC, true) { - sev = sdk.StatusCrit - } - return []issue{{ - Severity: sev, - Message: "The validating resolver did not set the AD flag: the record is not DNSSEC-authenticated, which defeats the whole DANE trust model.", - Hint: "Sign the zone with DNSSEC and publish the DS record at the parent so RFC 7929/8162 consumers can authenticate the key.", - }} -} - -func checkOwnerHashMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.ExpectedOwnerPrefix == "" || d.ObservedOwnerPrefix == "" { - return nil - } - if strings.EqualFold(d.ObservedOwnerPrefix, d.ExpectedOwnerPrefix) { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: fmt.Sprintf("Owner name prefix %q does not match SHA-256(%q)[:28]=%q.", d.ObservedOwnerPrefix, d.Username, d.ExpectedOwnerPrefix), - Hint: "Republish the record at the hash-derived name for the intended user, or update the Username field to match the record's owner name.", - }} -} diff --git a/checker/rules_pgp.go b/checker/rules_pgp.go deleted file mode 100644 index ca60a58..0000000 --- a/checker/rules_pgp.go +++ /dev/null @@ -1,213 +0,0 @@ -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 + "]" -} diff --git a/checker/rules_smimea.go b/checker/rules_smimea.go deleted file mode 100644 index 98e359d..0000000 --- a/checker/rules_smimea.go +++ /dev/null @@ -1,231 +0,0 @@ -package checker - -import ( - "fmt" - "strings" - "time" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -// SMIMEA-specific rules: field-value validity (usage/selector/matching -// type), certificate parse, validity window, extended key usage, key -// usage flags, signature-algorithm and key-size strength, self-signed -// handling, email SAN/username pairing, and digest-only guidance. - -func checkSMIMEABadUsage(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.SMIMEA == nil || d.SMIMEA.Usage <= 3 { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: fmt.Sprintf("Unknown SMIMEA usage %d (expected 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE).", d.SMIMEA.Usage), - Hint: "Use usage 3 (DANE-EE) for self-hosted S/MIME certificates.", - }} -} - -func checkSMIMEABadSelector(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.SMIMEA == nil || d.SMIMEA.Selector <= 1 { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: fmt.Sprintf("Unknown SMIMEA selector %d (expected 0 Cert or 1 SPKI).", d.SMIMEA.Selector), - Hint: "Use selector 0 to publish the full certificate.", - }} -} - -func checkSMIMEABadMatchType(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.SMIMEA == nil || d.SMIMEA.MatchingType <= 2 { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: fmt.Sprintf("Unknown SMIMEA matching type %d (expected 0 Full, 1 SHA-256, 2 SHA-512).", d.SMIMEA.MatchingType), - Hint: "Use matching type 0 so the whole certificate is transported, or type 1 (SHA-256) for a digest.", - }} -} - -func checkSMIMEACertParseError(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.SMIMEA == nil { - return []issue{{ - Severity: sdk.StatusCrit, - Message: "Service body has no SMIMEA record.", - Hint: "Attach a valid SMIMEA record to the service.", - }} - } - if d.SMIMEA.ParseError == "" { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: d.SMIMEA.ParseError, - Hint: "Ensure the certificate is DER-encoded (not PEM) before hex-encoding it into SMIMEA RDATA.", - }} -} - -func checkSMIMEACertNotYetValid(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - ci := smimeaCert(d) - if ci == nil || ci.NotBefore.IsZero() { - return nil - } - if !time.Now().Before(ci.NotBefore) { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: fmt.Sprintf("Certificate is not yet valid (NotBefore = %s).", ci.NotBefore.Format(time.RFC3339)), - Hint: "Check the system clock on the CA/signer, or wait until the certificate's notBefore date.", - }} -} - -func checkSMIMEACertExpired(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - ci := smimeaCert(d) - if ci == nil || ci.NotAfter.IsZero() { - return nil - } - if !time.Now().After(ci.NotAfter) { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: fmt.Sprintf("Certificate expired on %s.", ci.NotAfter.Format(time.RFC3339)), - Hint: "Issue a fresh certificate and republish the SMIMEA record.", - }} -} - -func checkSMIMEACertExpiring(d *EmailKeyData, opts sdk.CheckerOptions) []issue { - ci := smimeaCert(d) - if ci == nil || ci.NotAfter.IsZero() { - return nil - } - warnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30) - if warnDays <= 0 { - return nil - } - now := time.Now() - window := time.Duration(warnDays) * 24 * time.Hour - if ci.NotAfter.Before(now) || ci.NotAfter.Sub(now) >= window { - return nil - } - return []issue{{ - Severity: sdk.StatusWarn, - Message: fmt.Sprintf("Certificate expires on %s.", ci.NotAfter.Format(time.RFC3339)), - Hint: "Renew before expiry and update the SMIMEA record with the new certificate.", - }} -} - -func checkSMIMEANoEmailProtect(d *EmailKeyData, opts sdk.CheckerOptions) []issue { - ci := smimeaCert(d) - if ci == nil || ci.HasEmailProtectionEKU { - return nil - } - sev := sdk.StatusWarn - if sdk.GetBoolOption(opts, OptionRequireEmailProtection, true) { - sev = sdk.StatusCrit - } - return []issue{{ - Severity: sev, - Message: "Certificate lacks the emailProtection Extended Key Usage; RFC 8550/8551 agents will refuse it.", - Hint: "Re-issue the certificate with `extendedKeyUsage = emailProtection` (OID 1.3.6.1.5.5.7.3.4).", - }} -} - -func checkSMIMEAMissingKeyUsage(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - ci := smimeaCert(d) - if ci == nil || ci.HasDigitalSignature || ci.HasKeyEncipherment { - return nil - } - return []issue{{ - Severity: sdk.StatusWarn, - Message: "Certificate has neither digitalSignature nor keyEncipherment key usage; S/MIME signing or encryption will be refused.", - Hint: "Add `keyUsage = digitalSignature, keyEncipherment` to the certificate profile.", - }} -} - -var weakSMIMEASignatureAlgorithms = map[string]bool{ - "MD2-RSA": true, - "MD5-RSA": true, - "SHA1-RSA": true, - "DSA-SHA1": true, - "ECDSA-SHA1": true, -} - -func checkSMIMEAWeakSigAlgorithm(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - ci := smimeaCert(d) - if ci == nil || ci.SignatureAlgorithm == "" { - return nil - } - if !weakSMIMEASignatureAlgorithms[ci.SignatureAlgorithm] { - return nil - } - return []issue{{ - Severity: sdk.StatusCrit, - Message: fmt.Sprintf("Certificate is signed with %s, a deprecated algorithm.", ci.SignatureAlgorithm), - Hint: "Re-issue the certificate with SHA-256 (or better) signatures.", - }} -} - -func checkSMIMEAWeakKeySize(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.SMIMEA == nil { - return nil - } - algo, bits := "", 0 - switch { - case d.SMIMEA.Certificate != nil: - algo, bits = d.SMIMEA.Certificate.PublicKeyAlgorithm, d.SMIMEA.Certificate.PublicKeyBits - case d.SMIMEA.PublicKey != nil: - algo, bits = d.SMIMEA.PublicKey.Algorithm, d.SMIMEA.PublicKey.Bits - default: - return nil - } - if iss := rsaKeySizeIssue(algo, bits, "Certificate"); iss != nil { - return []issue{*iss} - } - return nil -} - -func checkSMIMEASelfSigned(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - ci := smimeaCert(d) - if ci == nil || !ci.IsSelfSigned { - return nil - } - if d.SMIMEA.Usage != 1 && d.SMIMEA.Usage != 3 { - return nil - } - return []issue{{ - Severity: sdk.StatusInfo, - Message: "End-entity usage advertises a self-signed certificate; DANE-EE (usage 3) makes this safe, but PKIX-EE (usage 1) consumers will reject it.", - Hint: "Switch the record to usage 3 (DANE-EE) if you operate your own CA, or chain the certificate under a public CA for usage 1.", - }} -} - -func checkSMIMEAEmailMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - ci := smimeaCert(d) - if ci == nil || ci.EmailMatchesUsername == nil || *ci.EmailMatchesUsername { - return nil - } - return []issue{{ - Severity: sdk.StatusInfo, - Message: fmt.Sprintf("None of the certificate's email SANs (%s) begin with %s@; clients that strictly match SAN to envelope address will reject it.", strings.Join(ci.EmailAddresses, ", "), d.Username), - Hint: "Re-issue the certificate with the correct `subjectAltName = email:@`.", - }} -} - -func checkSMIMEAHashOnly(d *EmailKeyData, _ sdk.CheckerOptions) []issue { - if d.SMIMEA == nil || d.SMIMEA.MatchingType == 0 { - return nil - } - return []issue{{ - Severity: sdk.StatusInfo, - Message: "Record carries only a digest; the certificate itself cannot be verified by this checker.", - Hint: "Switch to matching type 0 (Full) to let verifiers inspect and pin the certificate.", - }} -} - -func smimeaCert(d *EmailKeyData) *CertInfo { - if d.SMIMEA == nil { - return nil - } - return d.SMIMEA.Certificate -} diff --git a/checker/rules_test.go b/checker/rules_test.go deleted file mode 100644 index 7aee591..0000000 --- a/checker/rules_test.go +++ /dev/null @@ -1,330 +0,0 @@ -package checker - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - sdk "git.happydns.org/checker-sdk-go/checker" -) - -func boolPtr(b bool) *bool { return &b } - -// fakeObs implements sdk.ObservationGetter against an in-memory map. -type fakeObs struct { - store map[string]any -} - -func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error { - v, ok := f.store[key] - if !ok { - return errFake("missing observation: " + key) - } - raw, err := json.Marshal(v) - if err != nil { - return err - } - return json.Unmarshal(raw, dest) -} - -func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { - return nil, nil -} - -type errFake string - -func (e errFake) Error() string { return string(e) } - -// ── DNS rules ──────────────────────────────────────────────────────────────── - -func TestCheckDNSQueryFailed(t *testing.T) { - if got := checkDNSQueryFailed(&EmailKeyData{}, nil); got != nil { - t.Errorf("expected no issue, got %+v", got) - } - got := checkDNSQueryFailed(&EmailKeyData{DNSQueryError: "timeout"}, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusCrit { - t.Errorf("expected one crit issue, got %+v", got) - } -} - -func TestCheckDNSNoRecord(t *testing.T) { - // nil DNSAnswerPresent ⇒ no judgement. - if got := checkDNSNoRecord(&EmailKeyData{}, nil); got != nil { - t.Errorf("expected no issue when present is nil, got %+v", got) - } - // Present=true ⇒ no issue. - if got := checkDNSNoRecord(&EmailKeyData{DNSAnswerPresent: boolPtr(true)}, nil); got != nil { - t.Errorf("expected no issue when present, got %+v", got) - } - // Present=false ⇒ crit. - got := checkDNSNoRecord(&EmailKeyData{Kind: KindSMIMEA, QueriedOwner: "x", DNSAnswerPresent: boolPtr(false)}, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusCrit || !strings.Contains(got[0].Message, "SMIMEA") { - t.Errorf("unexpected: %+v", got) - } -} - -func TestCheckDNSSECNotValidated_Severity(t *testing.T) { - d := &EmailKeyData{DNSSECSecure: boolPtr(false)} - // Default: requireDNSSEC=true ⇒ crit. - got := checkDNSSECNotValidated(d, sdk.CheckerOptions{}) - if len(got) != 1 || got[0].Severity != sdk.StatusCrit { - t.Errorf("default should be crit, got %+v", got) - } - // Override to false ⇒ warn. - got = checkDNSSECNotValidated(d, sdk.CheckerOptions{OptionRequireDNSSEC: false}) - if len(got) != 1 || got[0].Severity != sdk.StatusWarn { - t.Errorf("opt-off should be warn, got %+v", got) - } - // Secure ⇒ no issue. - got = checkDNSSECNotValidated(&EmailKeyData{DNSSECSecure: boolPtr(true)}, nil) - if got != nil { - t.Errorf("expected no issue, got %+v", got) - } -} - -func TestCheckOwnerHashMismatch(t *testing.T) { - d := &EmailKeyData{Username: "alice", ExpectedOwnerPrefix: "abc", ObservedOwnerPrefix: "abc"} - if got := checkOwnerHashMismatch(d, nil); got != nil { - t.Errorf("matching prefixes should not issue, got %+v", got) - } - d.ObservedOwnerPrefix = "ABC" // case-insensitive - if got := checkOwnerHashMismatch(d, nil); got != nil { - t.Errorf("case-insensitive match should not issue, got %+v", got) - } - d.ObservedOwnerPrefix = "xyz" - got := checkOwnerHashMismatch(d, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusCrit { - t.Errorf("mismatch should crit, got %+v", got) - } - // Either prefix empty ⇒ skip silently. - d.ObservedOwnerPrefix = "" - if got := checkOwnerHashMismatch(d, nil); got != nil { - t.Errorf("empty observed should skip, got %+v", got) - } -} - -// ── PGP rules ──────────────────────────────────────────────────────────────── - -func TestCheckPGPParseError(t *testing.T) { - got := checkPGPParseError(&EmailKeyData{}, nil) - if len(got) != 1 || !strings.Contains(got[0].Message, "no OPENPGPKEY") { - t.Errorf("expected no-record issue, got %+v", got) - } - got = checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{ParseError: "boom"}}, nil) - if len(got) != 1 || got[0].Message != "boom" { - t.Errorf("expected parse-error issue, got %+v", got) - } - if got := checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{}}, nil); got != nil { - t.Errorf("expected no issue, got %+v", got) - } -} - -func TestCheckPGPPrimaryExpired(t *testing.T) { - past := time.Now().Add(-1 * time.Hour) - d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: past}} - got := checkPGPPrimaryExpired(d, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusCrit { - t.Errorf("expected crit, got %+v", got) - } - d.OpenPGP.ExpiresAt = time.Now().Add(time.Hour) - if got := checkPGPPrimaryExpired(d, nil); got != nil { - t.Errorf("future expiry should not issue, got %+v", got) - } -} - -func TestCheckPGPPrimaryExpiring(t *testing.T) { - soon := time.Now().Add(10 * 24 * time.Hour) - d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: soon}} - // Default 30-day window ⇒ warn. - got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{}) - if len(got) != 1 || got[0].Severity != sdk.StatusWarn { - t.Errorf("expected warn, got %+v", got) - } - // Already expired ⇒ this rule does not fire (the expired rule does). - d.OpenPGP.ExpiresAt = time.Now().Add(-time.Hour) - if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{}); got != nil { - t.Errorf("expired key should not trigger expiring rule, got %+v", got) - } - // Disable via warnDays=0 ⇒ no issue. - d.OpenPGP.ExpiresAt = soon - if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{OptionCertExpiryWarnDays: float64(0)}); got != nil { - t.Errorf("warnDays=0 should disable, got %+v", got) - } -} - -func TestCheckPGPWeakKeySize(t *testing.T) { - d := &EmailKeyData{OpenPGP: &OpenPGPInfo{PrimaryAlgorithm: "RSA", PrimaryBits: 1024}} - got := checkPGPWeakKeySize(d, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusCrit { - t.Errorf("1024-bit RSA should be crit, got %+v", got) - } - d.OpenPGP.PrimaryBits = 2048 - got = checkPGPWeakKeySize(d, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusWarn { - t.Errorf("2048-bit RSA should be warn, got %+v", got) - } - d.OpenPGP.PrimaryBits = 4096 - if got := checkPGPWeakKeySize(d, nil); got != nil { - t.Errorf("4096-bit RSA should pass, got %+v", got) - } - // Non-RSA ⇒ skip. - d.OpenPGP.PrimaryAlgorithm = "Ed25519" - d.OpenPGP.PrimaryBits = 256 - if got := checkPGPWeakKeySize(d, nil); got != nil { - t.Errorf("Ed25519 should skip, got %+v", got) - } -} - -func TestCheckPGPRecordTooLarge(t *testing.T) { - d := &EmailKeyData{OpenPGP: &OpenPGPInfo{RawSize: pgpMaxRecordBytes + 1}} - got := checkPGPRecordTooLarge(d, nil) - if len(got) != 1 { - t.Errorf("expected one issue, got %+v", got) - } - d.OpenPGP.RawSize = pgpMaxRecordBytes - if got := checkPGPRecordTooLarge(d, nil); got != nil { - t.Errorf("at-limit should pass, got %+v", got) - } -} - -func TestCheckPGPUIDMismatch(t *testing.T) { - d := &EmailKeyData{Username: "alice", OpenPGP: &OpenPGPInfo{MatchesUsername: boolPtr(false)}} - got := checkPGPUIDMismatch(d, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusInfo { - t.Errorf("expected info issue, got %+v", got) - } - d.OpenPGP.MatchesUsername = boolPtr(true) - if got := checkPGPUIDMismatch(d, nil); got != nil { - t.Errorf("matching should pass, got %+v", got) - } - d.OpenPGP.MatchesUsername = nil - if got := checkPGPUIDMismatch(d, nil); got != nil { - t.Errorf("nil should skip, got %+v", got) - } -} - -// ── SMIMEA rules ───────────────────────────────────────────────────────────── - -func TestCheckSMIMEAFieldRanges(t *testing.T) { - if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 4}}, nil); len(got) != 1 { - t.Errorf("usage=4 should issue, got %+v", got) - } - if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 3}}, nil); got != nil { - t.Errorf("usage=3 should pass, got %+v", got) - } - if got := checkSMIMEABadSelector(&EmailKeyData{SMIMEA: &SMIMEAInfo{Selector: 2}}, nil); len(got) != 1 { - t.Errorf("selector=2 should issue") - } - if got := checkSMIMEABadMatchType(&EmailKeyData{SMIMEA: &SMIMEAInfo{MatchingType: 3}}, nil); len(got) != 1 { - t.Errorf("matching=3 should issue") - } -} - -func TestCheckSMIMEACertExpired(t *testing.T) { - past := time.Now().Add(-time.Hour) - d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{NotAfter: past}}} - got := checkSMIMEACertExpired(d, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusCrit { - t.Errorf("expected crit, got %+v", got) - } -} - -func TestCheckSMIMEANoEmailProtect_Severity(t *testing.T) { - d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{}}} - // Default true ⇒ crit. - if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); len(got) != 1 || got[0].Severity != sdk.StatusCrit { - t.Errorf("default crit, got %+v", got) - } - // Off ⇒ warn. - if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{OptionRequireEmailProtection: false}); got[0].Severity != sdk.StatusWarn { - t.Errorf("opt-off should warn, got %+v", got) - } - // Has EKU ⇒ no issue. - d.SMIMEA.Certificate.HasEmailProtectionEKU = true - if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); got != nil { - t.Errorf("EKU present should pass, got %+v", got) - } -} - -func TestCheckSMIMEAWeakSigAlgorithm(t *testing.T) { - for _, algo := range []string{"MD5-RSA", "SHA1-RSA"} { - d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: algo}}} - if got := checkSMIMEAWeakSigAlgorithm(d, nil); len(got) != 1 { - t.Errorf("%s should issue", algo) - } - } - d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: "SHA256-RSA"}}} - if got := checkSMIMEAWeakSigAlgorithm(d, nil); got != nil { - t.Errorf("SHA256-RSA should pass, got %+v", got) - } -} - -func TestCheckSMIMEAEmailMismatch(t *testing.T) { - d := &EmailKeyData{Username: "alice", SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{ - EmailAddresses: []string{"bob@example.com"}, - EmailMatchesUsername: boolPtr(false), - }}} - got := checkSMIMEAEmailMismatch(d, nil) - if len(got) != 1 || got[0].Severity != sdk.StatusInfo { - t.Errorf("expected info, got %+v", got) - } -} - -// ── Rule.Evaluate plumbing ─────────────────────────────────────────────────── - -func TestRuleEvaluate_OKPath(t *testing.T) { - obs := &fakeObs{store: map[string]any{ - ObservationKey: &EmailKeyData{Kind: KindOpenPGPKey, QueriedOwner: "x.example.com.", DNSSECSecure: boolPtr(true)}, - }} - for _, r := range allRules { - if r.name != RuleDNSSECNotValidated { - continue - } - states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{}) - if len(states) != 1 || states[0].Status != sdk.StatusOK || states[0].Code != RuleDNSSECNotValidated { - t.Fatalf("expected single OK state, got %+v", states) - } - } -} - -func TestRuleEvaluate_KindFiltering(t *testing.T) { - obs := &fakeObs{store: map[string]any{ - ObservationKey: &EmailKeyData{Kind: KindSMIMEA}, - }} - for _, r := range allRules { - if r.name != RulePGPParseError { - continue - } - states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{}) - if len(states) != 1 || states[0].Status != sdk.StatusUnknown { - t.Fatalf("PGP rule on SMIMEA kind should yield single Unknown state, got %+v", states) - } - } -} - -func TestRuleEvaluate_MissingObservation(t *testing.T) { - obs := &fakeObs{store: map[string]any{}} - r := allRules[0] - states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{}) - if len(states) != 1 || states[0].Status != sdk.StatusError { - t.Fatalf("expected single Error state, got %+v", states) - } -} - -func TestRulesUniqueNames(t *testing.T) { - seen := map[string]bool{} - for _, r := range allRules { - if seen[r.name] { - t.Errorf("duplicate rule name: %s", r.name) - } - seen[r.name] = true - if r.check == nil { - t.Errorf("rule %s has nil check func", r.name) - } - if r.okMessage == "" { - t.Errorf("rule %s has empty okMessage", r.name) - } - } -} diff --git a/checker/types.go b/checker/types.go index 5085872..ca432eb 100644 --- a/checker/types.go +++ b/checker/types.go @@ -1,8 +1,8 @@ // 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. +// 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 ( @@ -25,9 +25,70 @@ const ( 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. -// 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"` @@ -43,8 +104,8 @@ type EmailKeyData struct { // 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 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 @@ -54,33 +115,10 @@ type EmailKeyData struct { // 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 (._openpgpkey.<…> / ._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"` @@ -90,17 +128,13 @@ type EmailKeyData struct { // 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 { - // 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"` @@ -130,11 +164,6 @@ type OpenPGPInfo struct { // 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 , - // false otherwise. - MatchesUsername *bool `json:"matches_username,omitempty"` - // Subkeys describes the subordinate keys. Subkeys []SubkeyInfo `json:"subkeys,omitempty"` @@ -161,10 +190,6 @@ type SubkeyInfo struct { // 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"` @@ -197,11 +222,6 @@ type CertInfo struct { 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 "@", false otherwise. - EmailMatchesUsername *bool `json:"email_matches_username,omitempty"` } // PubKeyInfo summarises an SPKI-only SMIMEA record. diff --git a/go.mod b/go.mod index 536ca56..541266a 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,17 @@ module git.happydns.org/checker-email-keys go 1.25.0 require ( - git.happydns.org/checker-sdk-go v1.7.0 - github.com/ProtonMail/go-crypto v1.4.1 + git.happydns.org/checker-sdk-go v1.1.0 + github.com/ProtonMail/go-crypto v1.1.0-alpha.0 github.com/miekg/dns v1.1.72 ) require ( - github.com/cloudflare/circl v1.6.3 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/tools v0.45.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index 33cca37..1a849ac 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,22 @@ -git.happydns.org/checker-sdk-go v1.7.0 h1:dSgo2js5mfXluLc6x0WWZ0MQULd9XV2GI9z0Usk+Qgw= -git.happydns.org/checker-sdk-go v1.7.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= -github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= -github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y= +git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/ProtonMail/go-crypto v1.1.0-alpha.0 h1:nHGfwXmFvJrSR9xu8qL7BkO4DqTHXE9N5vPhgY2I+j0= +github.com/ProtonMail/go-crypto v1.1.0-alpha.0/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= diff --git a/main.go b/main.go index 12d8ed2..d7e1801 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "log" emailkeys "git.happydns.org/checker-email-keys/checker" - "git.happydns.org/checker-sdk-go/checker/server" + sdk "git.happydns.org/checker-sdk-go/checker" ) // Version is the standalone binary's version. Override with: @@ -20,8 +20,8 @@ func main() { emailkeys.Version = Version - srv := server.New(emailkeys.Provider()) - if err := srv.ListenAndServe(*listenAddr); err != nil { + server := sdk.NewServer(emailkeys.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/plugin/plugin.go b/plugin/plugin.go index aab5595..34d62ae 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -17,6 +17,5 @@ var Version = "custom-build" // the .so file. func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { emailkeys.Version = Version - prvd := emailkeys.Provider() - return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil + return emailkeys.Definition(), emailkeys.Provider(), nil }