Compare commits
No commits in common. "master" and "v0.1.0" have entirely different histories.
24 changed files with 704 additions and 2074 deletions
|
|
@ -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
|
|
||||||
187
.drone.yml
187
.drone.yml
|
|
@ -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
|
|
||||||
|
|
@ -6,12 +6,9 @@ WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
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
|
FROM scratch
|
||||||
COPY --from=builder /checker-email-keys /checker-email-keys
|
COPY --from=builder /checker-email-keys /checker-email-keys
|
||||||
USER 65534:65534
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD ["/checker-email-keys", "-healthcheck"]
|
|
||||||
ENTRYPOINT ["/checker-email-keys"]
|
ENTRYPOINT ["/checker-email-keys"]
|
||||||
|
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||||
|
|
||||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||||
|
|
||||||
.PHONY: all plugin docker test clean
|
.PHONY: all plugin docker clean
|
||||||
|
|
||||||
all: $(CHECKER_NAME)
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||||
|
|
||||||
plugin: $(CHECKER_NAME).so
|
plugin: $(CHECKER_NAME).so
|
||||||
|
|
||||||
|
|
@ -21,8 +21,5 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||||
docker:
|
docker:
|
||||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||||
|
|
||||||
test:
|
|
||||||
go test -tags standalone ./...
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
|
|
|
||||||
89
README.md
89
README.md
|
|
@ -18,58 +18,53 @@ This checker binds to the happyDomain services:
|
||||||
[rfc7929]: https://www.rfc-editor.org/rfc/rfc7929
|
[rfc7929]: https://www.rfc-editor.org/rfc/rfc7929
|
||||||
[rfc8162]: https://www.rfc-editor.org/rfc/rfc8162
|
[rfc8162]: https://www.rfc-editor.org/rfc/rfc8162
|
||||||
|
|
||||||
## Security scope
|
## Tests run
|
||||||
|
|
||||||
This checker validates DNS publication and the structure/metadata of the
|
All findings are tagged by severity (`info` / `warn` / `crit`) so the
|
||||||
keys it finds. It does **not** cryptographically verify them:
|
rule engine can fold them into a single `CheckState`.
|
||||||
|
|
||||||
- OpenPGP signatures (self-signatures, third-party certifications,
|
### DNS (both record types)
|
||||||
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.
|
|
||||||
|
|
||||||
Treat a green report as "the record is well-formed and DNSSEC-signed",
|
| Code | Severity | What it catches |
|
||||||
not as "the key is trustworthy".
|
| --- | --- | --- |
|
||||||
|
| `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 |
|
| Code | Severity | What it catches |
|
||||||
|-----------------------------------|---------------------------------------------------------------------------------------------------|---------------------|
|
| --- | --- | --- |
|
||||||
| `dns_query_failed` | Verifies that the DNS lookup for the OPENPGPKEY/SMIMEA record succeeds. | CRITICAL |
|
| `pgp_parse_error` | crit | Malformed base64 or OpenPGP packet stream. |
|
||||||
| `dns_no_record` | Verifies that an OPENPGPKEY/SMIMEA record is published at the expected owner name. | CRITICAL |
|
| `pgp_no_entity` | crit | Record decoded but carries no valid entity. |
|
||||||
| `dns_record_mismatch` | Verifies that the record returned by DNS matches the service-declared record. | WARNING |
|
| `pgp_primary_revoked` | crit | Primary key has a revocation signature. |
|
||||||
| `dnssec_not_validated` | Verifies that the record is authenticated by DNSSEC (AD flag set). | CRITICAL |
|
| `pgp_primary_expired` | crit | Self-signature expired; clients will refuse to encrypt. |
|
||||||
| `owner_hash_mismatch` | Verifies that the owner-name first label equals hex(sha256(username))[:28]. | CRITICAL |
|
| `pgp_primary_expiring_soon` | warn | Expires within the `certExpiryWarnDays` window (default 30). |
|
||||||
| `pgp_parse_error` | Verifies that the OPENPGPKEY record decodes as a valid OpenPGP key. | CRITICAL |
|
| `pgp_weak_algorithm` | warn | Uses DSA / ElGamal (phase-out). |
|
||||||
| `pgp_primary_revoked` | Verifies that the OpenPGP primary key carries no revocation signature. | CRITICAL |
|
| `pgp_weak_key_size` | crit / warn | RSA below 2048 bits is critical, 2048-3071 is a warn. |
|
||||||
| `pgp_primary_expired` | Verifies that the OpenPGP primary key has not passed its self-signature expiry. | CRITICAL |
|
| `pgp_no_encryption_subkey` | crit | No active key in the entity advertises encryption capability. |
|
||||||
| `pgp_primary_expiring_soon` | Warns when the OpenPGP primary key expires within the configured window. | WARNING |
|
| `pgp_no_identity` | warn | No self-signed User ID. |
|
||||||
| `pgp_weak_algorithm` | Verifies that OpenPGP keys do not use legacy algorithms (DSA/ElGamal). | WARNING |
|
| `pgp_uid_mismatch` | info | None of the UIDs reference `<username@…>`. |
|
||||||
| `pgp_weak_key_size` | Verifies that OpenPGP RSA keys meet the minimum 2048-bit size (3072+ preferred). | CRITICAL |
|
| `pgp_multiple_entities` | warn | Record carries more than one entity (RFC 7929 recommends one). |
|
||||||
| `pgp_no_encryption_subkey` | Verifies that at least one active OpenPGP key advertises encryption capability. | CRITICAL |
|
| `pgp_record_too_large` | warn | Raw key > 4 KiB; forces UDP→TCP fallback on every lookup. |
|
||||||
| `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 <username@...>. | INFO |
|
### SMIMEA-specific (RFC 8162)
|
||||||
| `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 |
|
| Code | Severity | What it catches |
|
||||||
| `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_usage` / `_selector` / `_match_type` | crit | Field outside the allowed range. |
|
||||||
| `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` | crit | Hex-encoded blob is not a valid X.509 certificate / SPKI. |
|
||||||
| `smimea_cert_parse_error` | Verifies that the SMIMEA record decodes as a valid X.509 certificate or SPKI. | CRITICAL |
|
| `smimea_cert_expired` / `_not_yet_valid` | crit | `notBefore` / `notAfter` gate the current time out. |
|
||||||
| `smimea_cert_not_yet_valid` | Verifies that the S/MIME certificate's NotBefore is in the past. | CRITICAL |
|
| `smimea_cert_expiring_soon` | warn | Within the `certExpiryWarnDays` window. |
|
||||||
| `smimea_cert_expired` | Verifies that the S/MIME certificate's NotAfter is in the future. | CRITICAL |
|
| `smimea_no_email_protection_eku` | crit / warn | Missing `emailProtection` EKU (RFC 8550/8551 agents will reject). |
|
||||||
| `smimea_cert_expiring_soon` | Warns when the S/MIME certificate expires within the configured window. | WARNING |
|
| `smimea_missing_key_usage` | warn | Neither `digitalSignature` nor `keyEncipherment` key-usage is set. |
|
||||||
| `smimea_no_email_protection_eku` | Verifies that the S/MIME certificate advertises the emailProtection EKU. | CRITICAL |
|
| `smimea_email_mismatch` | info | No email SAN starts with `<username>@`. |
|
||||||
| `smimea_missing_key_usage` | Verifies that the certificate carries digitalSignature and/or keyEncipherment key usage. | WARNING |
|
| `smimea_weak_signature_algorithm` | crit | MD5 / SHA-1 based signature. |
|
||||||
| `smimea_weak_signature_algorithm` | Verifies that the certificate is not signed with a deprecated algorithm (MD2/MD5/SHA-1). | CRITICAL |
|
| `smimea_weak_key_size` | crit / warn | RSA < 2048 / 3072 bits. |
|
||||||
| `smimea_weak_key_size` | Verifies that SMIMEA RSA keys meet the minimum 2048-bit size (3072+ preferred). | CRITICAL |
|
| `smimea_self_signed` | info | Self-signed certificate paired with PKIX-EE usage. |
|
||||||
| `smimea_self_signed` | Flags self-signed certificates paired with PKIX-EE (usage 1). | INFO |
|
| `smimea_hash_only` | info | Matching-type 1/2 only carries a digest; certificate can't be inspected. |
|
||||||
| `smimea_email_mismatch` | Checks that at least one email SAN on the certificate begins with <username>@. | INFO |
|
|
||||||
| `smimea_hash_only` | Notes that SMIMEA matching types 1/2 transport only a digest, preventing certificate inspection. | INFO |
|
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,6 @@ import (
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
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.
|
// serviceBody is the common envelope for the two services.
|
||||||
type serviceBody struct {
|
type serviceBody struct {
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
|
|
@ -39,11 +31,12 @@ type serviceBody struct {
|
||||||
SMIMEA *dns.SMIMEA `json:"smimea,omitempty"`
|
SMIMEA *dns.SMIMEA `json:"smimea,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect runs the DANE-email data gathering pipeline and returns an
|
// Collect runs the full DANE-email testsuite and returns an *EmailKeyData
|
||||||
// *EmailKeyData carrying raw facts (DNS outcome, parsed key / cert
|
// carrying every finding it produced. The function never returns an error
|
||||||
// structure). Judgment, severity, fix hints, option-driven thresholds,
|
// for domain-level problems; they are recorded as findings so that a
|
||||||
// is deferred to the rules. A non-nil error is returned only for
|
// subsequent call from the rule can fold them into a single CheckState.
|
||||||
// unrecoverable input problems (missing options, unknown service type).
|
// 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) {
|
func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||||
svcMsg, err := serviceFromOptions(opts)
|
svcMsg, err := serviceFromOptions(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -63,6 +56,9 @@ func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
||||||
originOpt, _ := sdk.GetOption[string](opts, "domain_name")
|
originOpt, _ := sdk.GetOption[string](opts, "domain_name")
|
||||||
subdomainOpt, _ := sdk.GetOption[string](opts, "subdomain")
|
subdomainOpt, _ := sdk.GetOption[string](opts, "subdomain")
|
||||||
resolverOpt, _ := sdk.GetOption[string](opts, OptionResolver)
|
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), ".")
|
origin := strings.TrimSuffix(firstNonEmpty(originOpt, svcMsg.Domain), ".")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
|
|
@ -86,10 +82,17 @@ func (p *emailKeyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
||||||
data.ExpectedOwner = expectedOwner
|
data.ExpectedOwner = expectedOwner
|
||||||
data.QueriedOwner = firstNonEmpty(recordedOwner, 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 != "" {
|
if data.Username != "" {
|
||||||
data.ExpectedOwnerPrefix = ownerHashHex(data.Username)
|
actualPrefix, want := extractOwnerPrefix(data.QueriedOwner, prefix, parent), ownerHashHex(data.Username)
|
||||||
data.ObservedOwnerPrefix = extractOwnerPrefix(data.QueriedOwner, prefix, parent)
|
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.
|
// 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)
|
ans, err := lookup(ctx, servers, data.QueriedOwner, qtype)
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
data.Resolver = ans.Server
|
data.Resolver = ans.Server
|
||||||
secure := ans.AD
|
secure := ans.AD
|
||||||
data.DNSSECSecure = &secure
|
data.DNSSECSecure = &secure
|
||||||
data.RecordCount = len(ans.Records)
|
data.RecordCount = len(ans.Records)
|
||||||
|
|
||||||
present := !(ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0)
|
if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 {
|
||||||
data.DNSAnswerPresent = &present
|
data.Findings = append(data.Findings, Finding{
|
||||||
|
Code: CodeDNSNoRecord,
|
||||||
// Compare DNS-returned record bytes with the service-declared ones
|
Severity: SeverityCrit,
|
||||||
// only when we actually have records to compare and a reference.
|
Message: fmt.Sprintf("Authoritative DNS returned no %s record at %s.", dns.TypeToString[qtype], data.QueriedOwner),
|
||||||
if present {
|
Fix: "Ensure the record is present in the zone and that the zone has been loaded by the authoritative servers.",
|
||||||
var match bool
|
})
|
||||||
switch {
|
} else {
|
||||||
case kind == KindOpenPGPKey && body.OpenPGP != nil:
|
if !ans.AD {
|
||||||
match = anyOpenPGPMatches(ans.Records, body.OpenPGP)
|
sev := SeverityWarn
|
||||||
data.DNSRecordMatchesService = &match
|
if requireDNSSEC {
|
||||||
case kind == KindSMIMEA && body.SMIMEA != nil:
|
sev = SeverityCrit
|
||||||
match = anySMIMEAMatches(ans.Records, body.SMIMEA)
|
}
|
||||||
data.DNSRecordMatchesService = &match
|
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
|
// Parse the payload from the service body (so we can analyze even if
|
||||||
// when the DNS lookup failed to reach the authoritative servers).
|
// DNS lookup failed to reach the authoritative servers).
|
||||||
if kind == KindOpenPGPKey {
|
if kind == KindOpenPGPKey {
|
||||||
data.OpenPGP = analyzeOpenPGP(body)
|
data.OpenPGP, data.Findings = analyzeOpenPGP(body, data.Findings, time.Duration(expiryWarnDays)*24*time.Hour)
|
||||||
} else {
|
} else {
|
||||||
data.SMIMEA = analyzeSMIMEA(body)
|
data.SMIMEA, data.Findings = analyzeSMIMEA(body, data.Findings, time.Duration(expiryWarnDays)*24*time.Hour, requireEmailProtection)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
|
|
@ -193,13 +221,11 @@ func computeOwner(body serviceBody, prefix, parent string) (expected, recorded s
|
||||||
// Normalise: no double dots.
|
// Normalise: no double dots.
|
||||||
expected = strings.Replace(expected, "..", ".", -1)
|
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 {
|
switch {
|
||||||
case body.OpenPGP != nil && body.OpenPGP.Hdr.Name != "":
|
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 != "":
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -249,45 +275,69 @@ func anySMIMEAMatches(rrs []dns.RR, ref *dns.SMIMEA) bool {
|
||||||
// ── OpenPGP analysis ─────────────────────────────────────────────────────────
|
// ── OpenPGP analysis ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// analyzeOpenPGP parses the OpenPGP key from the service record and
|
// analyzeOpenPGP parses the OpenPGP key from the service record and
|
||||||
// returns a structured fact summary. When parsing fails, ParseError is
|
// emits findings. It returns the summary and the (possibly extended)
|
||||||
// populated and the rest of the fields hold whatever could be recovered.
|
// findings list.
|
||||||
func analyzeOpenPGP(body serviceBody) *OpenPGPInfo {
|
func analyzeOpenPGP(body serviceBody, findings []Finding, expiryWarn time.Duration) (*OpenPGPInfo, []Finding) {
|
||||||
if body.OpenPGP == nil {
|
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
|
raw, err := base64.StdEncoding.DecodeString(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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &OpenPGPInfo{ParseError: fmt.Sprintf("OPENPGPKEY record carries invalid base64: %v", err)}
|
findings = append(findings, Finding{
|
||||||
}
|
Code: CodePGPParseError,
|
||||||
if len(raw) > maxKeyMaterialBytes {
|
Severity: SeverityCrit,
|
||||||
return &OpenPGPInfo{
|
Message: fmt.Sprintf("OPENPGPKEY record carries invalid base64: %v", err),
|
||||||
RawSize: len(raw),
|
Fix: "Re-export the public key as a binary OpenPGP packet stream (no ASCII armor) and base64 it exactly as stored in the RDATA.",
|
||||||
ParseError: fmt.Sprintf("OPENPGPKEY payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes),
|
})
|
||||||
}
|
return nil, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
info := &OpenPGPInfo{RawSize: len(raw)}
|
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))
|
entities, err := openpgp.ReadKeyRing(bytes.NewReader(raw))
|
||||||
if err != nil || len(entities) == 0 {
|
if err != nil || len(entities) == 0 {
|
||||||
|
// Fallback: try parsing as a single packet stream; some
|
||||||
|
// implementations omit markers between entities.
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = fmt.Errorf("no OpenPGP entity found")
|
err = fmt.Errorf("no OpenPGP entity found")
|
||||||
}
|
}
|
||||||
info.ParseError = fmt.Sprintf("Cannot parse OpenPGP key: %v", err)
|
findings = append(findings, Finding{
|
||||||
return info
|
Code: CodePGPParseError,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("Cannot parse OpenPGP key: %v", err),
|
||||||
|
Fix: "Regenerate the key with `gpg --export <fpr> | base64` and paste the result; do not armor the key.",
|
||||||
|
})
|
||||||
|
return info, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
info.EntityCount = len(entities)
|
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]
|
ent := entities[0]
|
||||||
pub := ent.PrimaryKey
|
pub := ent.PrimaryKey
|
||||||
|
|
@ -297,24 +347,55 @@ func analyzeOpenPGP(body serviceBody) *OpenPGPInfo {
|
||||||
info.PrimaryAlgorithm = algorithmName(pub)
|
info.PrimaryAlgorithm = algorithmName(pub)
|
||||||
info.PrimaryBits = publicKeyBits(pub)
|
info.PrimaryBits = publicKeyBits(pub)
|
||||||
|
|
||||||
|
// Identity UIDs.
|
||||||
for name := range ent.Identities {
|
for name := range ent.Identities {
|
||||||
info.UIDs = append(info.UIDs, name)
|
info.UIDs = append(info.UIDs, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revocations on the primary key.
|
||||||
if len(ent.Revocations) > 0 {
|
if len(ent.Revocations) > 0 {
|
||||||
info.Revoked = true
|
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.
|
// Expiry on the primary key, derived from the self-signature.
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil {
|
if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil {
|
||||||
if selfSig.KeyLifetimeSecs != nil && *selfSig.KeyLifetimeSecs > 0 {
|
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 <fpr>` → `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.
|
// Identity presence and UID vs username matching.
|
||||||
if len(ent.Identities) > 0 && body.Username != "" {
|
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 <fpr>` → `adduid`) and re-export.",
|
||||||
|
})
|
||||||
|
} else if body.Username != "" {
|
||||||
wantedLocal := strings.ToLower(body.Username)
|
wantedLocal := strings.ToLower(body.Username)
|
||||||
matched := false
|
matched := false
|
||||||
for name := range ent.Identities {
|
for name := range ent.Identities {
|
||||||
|
|
@ -324,7 +405,19 @@ func analyzeOpenPGP(body serviceBody) *OpenPGPInfo {
|
||||||
break
|
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.
|
// Subkeys + encryption capability.
|
||||||
|
|
@ -350,6 +443,9 @@ func analyzeOpenPGP(body serviceBody) *OpenPGPInfo {
|
||||||
if si.CanEncrypt && !si.Revoked && (si.ExpiresAt.IsZero() || si.ExpiresAt.After(now)) {
|
if si.CanEncrypt && !si.Revoked && (si.ExpiresAt.IsZero() || si.ExpiresAt.After(now)) {
|
||||||
info.HasEncryptionCapability = true
|
info.HasEncryptionCapability = true
|
||||||
}
|
}
|
||||||
|
if warn := pgpAlgorithmWarning(sk.PublicKey); warn != nil {
|
||||||
|
findings = append(findings, *warn)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Primary can also be an encryption key if flagged so.
|
// Primary can also be an encryption key if flagged so.
|
||||||
if selfSig, _ := ent.PrimarySelfSignature(); selfSig != nil && selfSig.FlagsValid &&
|
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.Revoked && (info.ExpiresAt.IsZero() || info.ExpiresAt.After(now)) {
|
||||||
info.HasEncryptionCapability = true
|
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 <fpr>` → `addkey`) and re-export.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return info
|
return info, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func algorithmName(pub *packet.PublicKey) string {
|
func algorithmName(pub *packet.PublicKey) string {
|
||||||
|
|
@ -418,13 +522,53 @@ func publicKeyBits(pub *packet.PublicKey) int {
|
||||||
return 0
|
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 ──────────────────────────────────────────────────────────
|
// ── SMIMEA analysis ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// analyzeSMIMEA parses the SMIMEA certificate and returns a structured
|
// analyzeSMIMEA parses the SMIMEA certificate, computes a structured
|
||||||
// fact summary. When parsing fails, ParseError is populated.
|
// summary, and emits findings.
|
||||||
func analyzeSMIMEA(body serviceBody) *SMIMEAInfo {
|
func analyzeSMIMEA(body serviceBody, findings []Finding, expiryWarn time.Duration, requireEmailProtection bool) (*SMIMEAInfo, []Finding) {
|
||||||
if body.SMIMEA == nil {
|
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
|
rec := body.SMIMEA
|
||||||
|
|
||||||
|
|
@ -435,30 +579,60 @@ func analyzeSMIMEA(body serviceBody) *SMIMEAInfo {
|
||||||
HashHex: strings.ToLower(rec.Certificate),
|
HashHex: strings.ToLower(rec.Certificate),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matching types 1 and 2 only carry a digest; no certificate or SPKI
|
// Usage (RFC 6698 + 8162): 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE.
|
||||||
// to parse. Rules surface that; here we just stop.
|
if rec.Usage > 3 {
|
||||||
if rec.MatchingType != 0 {
|
findings = append(findings, Finding{
|
||||||
return info
|
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 {
|
// Matching types 1 and 2 only carry a digest; no certificate to
|
||||||
info.ParseError = fmt.Sprintf("SMIMEA payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes)
|
// parse. Surface that as info so the user knows the checker's
|
||||||
return info
|
// 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)
|
der, err := hex.DecodeString(rec.Certificate)
|
||||||
if err != nil || len(der) == 0 {
|
if err != nil || len(der) == 0 {
|
||||||
info.ParseError = fmt.Sprintf("Cannot decode certificate bytes: %v", err)
|
findings = append(findings, Finding{
|
||||||
return info
|
Code: CodeSMIMEACertParseError,
|
||||||
}
|
Severity: SeverityCrit,
|
||||||
if len(der) > maxKeyMaterialBytes {
|
Message: fmt.Sprintf("Cannot decode certificate bytes: %v", err),
|
||||||
info.ParseError = fmt.Sprintf("SMIMEA payload exceeds the %d-byte parse limit.", maxKeyMaterialBytes)
|
Fix: "Re-export the certificate as DER and hex-encode it into the SMIMEA RDATA.",
|
||||||
return info
|
})
|
||||||
|
return info, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selector 1 carries only a SubjectPublicKeyInfo; parse it that way.
|
// Selector 1 carries only a SubjectPublicKeyInfo; parse it that way.
|
||||||
if rec.Selector == 1 {
|
if rec.Selector == 1 {
|
||||||
info.PublicKey = analyzeSPKI(der, info)
|
info.PublicKey = analyzeSPKI(der, &findings)
|
||||||
return info
|
return info, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
cert, err := x509.ParseCertificate(der)
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
|
@ -472,8 +646,13 @@ func analyzeSMIMEA(body serviceBody) *SMIMEAInfo {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = fmt.Errorf("no certificate found")
|
err = fmt.Errorf("no certificate found")
|
||||||
}
|
}
|
||||||
info.ParseError = fmt.Sprintf("Cannot parse X.509 certificate: %v", err)
|
findings = append(findings, Finding{
|
||||||
return info
|
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{
|
ci := &CertInfo{
|
||||||
|
|
@ -503,8 +682,98 @@ func analyzeSMIMEA(body serviceBody) *SMIMEAInfo {
|
||||||
ci.HasKeyEncipherment = true
|
ci.HasKeyEncipherment = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email-address / username pairing fact.
|
info.Certificate = ci
|
||||||
if body.Username != "" && len(cert.EmailAddresses) > 0 {
|
|
||||||
|
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) + "@"
|
wantPrefix := strings.ToLower(body.Username) + "@"
|
||||||
matched := false
|
matched := false
|
||||||
for _, e := range cert.EmailAddresses {
|
for _, e := range cert.EmailAddresses {
|
||||||
|
|
@ -513,31 +782,42 @@ func analyzeSMIMEA(body serviceBody) *SMIMEAInfo {
|
||||||
break
|
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:<user>@<domain>`.",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info.Certificate = ci
|
return info, findings
|
||||||
return info
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func analyzeSPKI(der []byte, info *SMIMEAInfo) *PubKeyInfo {
|
func analyzeSPKI(der []byte, findings *[]Finding) *PubKeyInfo {
|
||||||
pub, err := x509.ParsePKIXPublicKey(der)
|
pub, err := x509.ParsePKIXPublicKey(der)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
pk := &PubKeyInfo{Bits: x509PublicKeyBits(pub)}
|
info := &PubKeyInfo{Bits: x509PublicKeyBits(pub)}
|
||||||
switch pub.(type) {
|
switch pub.(type) {
|
||||||
case *rsa.PublicKey:
|
case *rsa.PublicKey:
|
||||||
pk.Algorithm = "RSA"
|
info.Algorithm = "RSA"
|
||||||
case *ecdsa.PublicKey:
|
case *ecdsa.PublicKey:
|
||||||
pk.Algorithm = "ECDSA"
|
info.Algorithm = "ECDSA"
|
||||||
case ed25519.PublicKey:
|
case ed25519.PublicKey:
|
||||||
pk.Algorithm = "Ed25519"
|
info.Algorithm = "Ed25519"
|
||||||
default:
|
default:
|
||||||
pk.Algorithm = fmt.Sprintf("%T", pub)
|
info.Algorithm = fmt.Sprintf("%T", pub)
|
||||||
}
|
}
|
||||||
return pk
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
func x509PublicKeyBits(pub any) int {
|
func x509PublicKeyBits(pub any) int {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -19,9 +19,9 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Definition is the package-level helper returned to the host by the
|
// 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.
|
// CheckerDefinitionProvider implementation.
|
||||||
func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition {
|
func Definition() *sdk.CheckerDefinition {
|
||||||
return &sdk.CheckerDefinition{
|
return &sdk.CheckerDefinition{
|
||||||
ID: "openpgpkey-smimea",
|
ID: "openpgpkey-smimea",
|
||||||
Name: "OPENPGPKEY & SMIMEA",
|
Name: "OPENPGPKEY & SMIMEA",
|
||||||
|
|
@ -43,6 +43,27 @@ func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition {
|
||||||
Placeholder: "1.1.1.1",
|
Placeholder: "1.1.1.1",
|
||||||
Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.",
|
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{
|
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||||
{
|
{
|
||||||
|
|
@ -70,7 +91,7 @@ func (p *emailKeyProvider) Definition() *sdk.CheckerDefinition {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Rules: Rules(),
|
Rules: []sdk.CheckRule{Rule()},
|
||||||
Interval: &sdk.CheckIntervalSpec{
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
Min: 1 * time.Hour,
|
Min: 1 * time.Hour,
|
||||||
Max: 7 * 24 * time.Hour,
|
Max: 7 * 24 * time.Hour,
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,6 @@ import (
|
||||||
// dnsTimeout is the per-query deadline used by every helper here.
|
// dnsTimeout is the per-query deadline used by every helper here.
|
||||||
const dnsTimeout = 5 * time.Second
|
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.
|
// dnsLookupAnswer is the subset of a DNS answer this checker cares about.
|
||||||
type dnsLookupAnswer struct {
|
type dnsLookupAnswer struct {
|
||||||
// Records are the answer records of the requested type.
|
// 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,
|
Server: srv,
|
||||||
}
|
}
|
||||||
for _, rr := range in.Answer {
|
for _, rr := range in.Answer {
|
||||||
if rr.Header().Rrtype != qtype {
|
if rr.Header().Rrtype == qtype {
|
||||||
continue
|
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 {
|
if in.Rcode == dns.RcodeSuccess || in.Rcode == dns.RcodeNameError {
|
||||||
return ans, nil
|
return ans, nil
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
//go:build standalone
|
|
||||||
|
|
||||||
package checker
|
package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -7,16 +5,14 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
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
|
// inputs needed to bootstrap a standalone OPENPGPKEY/SMIMEA check: an
|
||||||
// email address (the local part is hashed into the owner name) and a
|
// email address (the local part is hashed into the owner name) and a
|
||||||
// kind selector. The DNS resolver and severity-tuning options mirror
|
// 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
|
// resolves the DNS record matching the requested kind, and returns the
|
||||||
// CheckerOptions that Collect expects, including a synthesised service
|
// CheckerOptions that Collect expects, including a synthesised service
|
||||||
// envelope built from the live DNS answer.
|
// 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))
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), dnsTimeout*3)
|
||||||
defer cancel()
|
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.
|
// parseFloatOr parses a decimal string, returning fallback on error.
|
||||||
func parseFloatOr(s string, fallback float64) float64 {
|
func parseFloatOr(s string, fallback float64) float64 {
|
||||||
f, err := strconv.ParseFloat(s, 64)
|
var f float64
|
||||||
if err != nil {
|
if _, err := fmt.Sscanf(s, "%f", &f); err != nil {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile-time assertion that the provider implements the optional interface.
|
// Compile-time assertion that the provider implements the optional interface.
|
||||||
var _ server.Interactive = (*emailKeyProvider)(nil)
|
var _ sdk.CheckerInteractive = (*emailKeyProvider)(nil)
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,9 @@ type emailKeyProvider struct{}
|
||||||
func (p *emailKeyProvider) Key() sdk.ObservationKey {
|
func (p *emailKeyProvider) Key() sdk.ObservationKey {
|
||||||
return 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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ type reportData struct {
|
||||||
|
|
||||||
Remediations []remediation
|
Remediations []remediation
|
||||||
Findings []findingRow
|
Findings []findingRow
|
||||||
HasStates bool // true when rule states were threaded; gates the Findings section
|
|
||||||
CritCount int
|
CritCount int
|
||||||
WarnCount int
|
WarnCount int
|
||||||
InfoCount int
|
InfoCount int
|
||||||
|
|
@ -108,7 +107,7 @@ func (p *emailKeyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error)
|
||||||
return "", fmt.Errorf("unmarshal report data: %w", err)
|
return "", fmt.Errorf("unmarshal report data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rd := buildReportData(&data, ctx.States())
|
rd := buildReportData(&data)
|
||||||
|
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
if err := reportTemplate.Execute(&buf, rd); err != nil {
|
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
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData {
|
func buildReportData(d *EmailKeyData) reportData {
|
||||||
rd := reportData{
|
rd := reportData{
|
||||||
Kind: d.Kind,
|
Kind: d.Kind,
|
||||||
QueriedOwner: d.QueriedOwner,
|
QueriedOwner: d.QueriedOwner,
|
||||||
|
|
@ -137,52 +136,22 @@ func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData {
|
||||||
rd.DNSSEC = "insecure"
|
rd.DNSSEC = "insecure"
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.Kind == KindOpenPGPKey && d.OpenPGP != nil {
|
// Sort findings by severity (crit first) for stable display.
|
||||||
rd.OpenPGP = buildOpenPGPView(d.OpenPGP)
|
findings := make([]Finding, len(d.Findings))
|
||||||
}
|
copy(findings, d.Findings)
|
||||||
if d.Kind == KindSMIMEA && d.SMIMEA != nil {
|
sort.SliceStable(findings, func(i, j int) bool {
|
||||||
rd.SMIMEA = buildSMIMEAView(d.SMIMEA)
|
return severityRank(findings[i].Severity) > severityRank(findings[j].Severity)
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
})
|
})
|
||||||
for _, s := range kept {
|
for _, f := range findings {
|
||||||
rd.Findings = append(rd.Findings, findingRow{
|
rd.Findings = append(rd.Findings, findingRow{
|
||||||
Code: s.Code,
|
Code: f.Code, Severity: string(f.Severity), Message: f.Message, Fix: f.Fix,
|
||||||
Severity: severityLabel(s.Status),
|
|
||||||
Message: s.Message,
|
|
||||||
Fix: stateHint(s),
|
|
||||||
})
|
})
|
||||||
switch s.Status {
|
switch f.Severity {
|
||||||
case sdk.StatusCrit, sdk.StatusError:
|
case SeverityCrit:
|
||||||
rd.CritCount++
|
rd.CritCount++
|
||||||
case sdk.StatusWarn:
|
case SeverityWarn:
|
||||||
rd.WarnCount++
|
rd.WarnCount++
|
||||||
case sdk.StatusInfo:
|
case SeverityInfo:
|
||||||
rd.InfoCount++
|
rd.InfoCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -202,45 +171,18 @@ func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData {
|
||||||
rd.Headline = "All checks passed"
|
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
|
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 {
|
func buildOpenPGPView(o *OpenPGPInfo) *openPGPView {
|
||||||
v := &openPGPView{
|
v := &openPGPView{
|
||||||
Fingerprint: formatFingerprint(o.Fingerprint),
|
Fingerprint: formatFingerprint(o.Fingerprint),
|
||||||
|
|
@ -306,25 +248,28 @@ func buildSMIMEAView(s *SMIMEAInfo) *smimeaView {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRemediations surfaces a focused, user-actionable card for each
|
// buildRemediations detects the most common failure scenarios and
|
||||||
// of the most common failure scenarios present in `states`. Only rules
|
// surfaces a focused, user-actionable card for each. Only matching
|
||||||
// with a matching state produce a remediation; a clean run shows none.
|
// issues produce a remediation; a clean run shows none.
|
||||||
func buildRemediations(d *EmailKeyData, states []sdk.CheckState) []remediation {
|
func buildRemediations(d *EmailKeyData) []remediation {
|
||||||
var out []remediation
|
var out []remediation
|
||||||
|
|
||||||
byCode := map[string]bool{}
|
byCode := map[string]Finding{}
|
||||||
for _, s := range states {
|
for _, f := range d.Findings {
|
||||||
byCode[s.Code] = true
|
// 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) {
|
pick := func(code, title, body string) {
|
||||||
if !byCode[code] {
|
if _, ok := byCode[code]; !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out = append(out, remediation{Title: title, Body: template.HTML(body)})
|
out = append(out, remediation{Title: title, Body: template.HTML(body)})
|
||||||
}
|
}
|
||||||
|
|
||||||
pick(RuleDNSNoRecord,
|
pick(CodeDNSNoRecord,
|
||||||
"Publish the record in DNS",
|
"Publish the record in DNS",
|
||||||
fmt.Sprintf(`No <code>%s</code> record resolves at <code>%s</code>. Publish it in the zone and reload the authoritative servers.<br><br>
|
fmt.Sprintf(`No <code>%s</code> record resolves at <code>%s</code>. Publish it in the zone and reload the authoritative servers.<br><br>
|
||||||
Quick checklist:
|
Quick checklist:
|
||||||
|
|
@ -340,7 +285,7 @@ Quick checklist:
|
||||||
kindRRType(d.Kind),
|
kindRRType(d.Kind),
|
||||||
template.HTMLEscapeString(d.QueriedOwner)))
|
template.HTMLEscapeString(d.QueriedOwner)))
|
||||||
|
|
||||||
pick(RuleDNSSECNotValidated,
|
pick(CodeDNSNotSecure,
|
||||||
"Enable DNSSEC on the zone",
|
"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.<br><br>
|
`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.<br><br>
|
||||||
Steps:
|
Steps:
|
||||||
|
|
@ -350,14 +295,14 @@ Steps:
|
||||||
<li>Re-run this checker; the AD flag should light up.</li>
|
<li>Re-run this checker; the AD flag should light up.</li>
|
||||||
</ol>`)
|
</ol>`)
|
||||||
|
|
||||||
pick(RuleOwnerHashMismatch,
|
pick(CodeOwnerHashMismatch,
|
||||||
"Fix the record's owner-name hash",
|
"Fix the record's owner-name hash",
|
||||||
`The record is published at a name whose first label does not equal <code>hex(sha256(localpart))[:56]</code> (28 bytes). Email agents will never find it because they compute the hash from the recipient address.<br><br>
|
`The record is published at a name whose first label does not equal <code>hex(sha256(localpart))[:56]</code> (28 bytes). Email agents will never find it because they compute the hash from the recipient address.<br><br>
|
||||||
Compute the correct name:<br>
|
Compute the correct name:<br>
|
||||||
<pre>printf '%s' "<em>local-part</em>" | openssl dgst -sha256 | cut -c 1-56 | tr -d '\n' ; echo ".<em>_openpgpkey</em>.<em>domain.tld</em>"</pre>
|
<pre>printf '%s' "<em>local-part</em>" | openssl dgst -sha256 | cut -c 1-56 | tr -d '\n' ; echo ".<em>_openpgpkey</em>.<em>domain.tld</em>"</pre>
|
||||||
Then republish the record at that owner name.`)
|
Then republish the record at that owner name.`)
|
||||||
|
|
||||||
pick(RulePGPPrimaryExpired,
|
pick(CodePGPExpired,
|
||||||
"Renew the expired OpenPGP key",
|
"Renew the expired OpenPGP key",
|
||||||
`The primary key's self-signature expired, so clients will refuse to encrypt to it.<br>
|
`The primary key's self-signature expired, so clients will refuse to encrypt to it.<br>
|
||||||
<pre>gpg --edit-key <fingerprint>
|
<pre>gpg --edit-key <fingerprint>
|
||||||
|
|
@ -367,12 +312,12 @@ gpg> save
|
||||||
gpg --export <fingerprint> | base64</pre>
|
gpg --export <fingerprint> | base64</pre>
|
||||||
Paste the resulting base64 back into the OPENPGPKEY record.`)
|
Paste the resulting base64 back into the OPENPGPKEY record.`)
|
||||||
|
|
||||||
pick(RulePGPPrimaryRevoked,
|
pick(CodePGPRevoked,
|
||||||
"Publish a fresh, non-revoked key",
|
"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.<br><br>
|
`The record carries a revoked primary key; clients will stop encrypting mail to this address as soon as they process the revocation.<br><br>
|
||||||
Either generate a new key pair and publish it here, or remove the OPENPGPKEY record so senders fall back to regular email (unencrypted).`)
|
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",
|
"Add an encryption subkey",
|
||||||
`Every non-revoked key in the record is marked sign-only. Mail clients will refuse to encrypt to this record.<br>
|
`Every non-revoked key in the record is marked sign-only. Mail clients will refuse to encrypt to this record.<br>
|
||||||
<pre>gpg --edit-key <fingerprint>
|
<pre>gpg --edit-key <fingerprint>
|
||||||
|
|
@ -381,14 +326,14 @@ gpg> addkey
|
||||||
gpg> save</pre>
|
gpg> save</pre>
|
||||||
Re-export and republish.`)
|
Re-export and republish.`)
|
||||||
|
|
||||||
pick(RulePGPWeakKeySize,
|
pick(CodePGPWeakKeySize,
|
||||||
"Rotate away from weak RSA keys",
|
"Rotate away from weak RSA keys",
|
||||||
`RSA below 2048 bits is considered broken. Generate a modern key and republish:<br>
|
`RSA below 2048 bits is considered broken. Generate a modern key and republish:<br>
|
||||||
<pre>gpg --full-generate-key
|
<pre>gpg --full-generate-key
|
||||||
# choose 1 (RSA+RSA) with 3072/4096 bits,
|
# choose 1 (RSA+RSA) with 3072/4096 bits,
|
||||||
# or 9 (ECC+ECC) for Curve25519.</pre>`)
|
# or 9 (ECC+ECC) for Curve25519.</pre>`)
|
||||||
|
|
||||||
pick(RuleSMIMEACertExpired,
|
pick(CodeSMIMEACertExpired,
|
||||||
"Renew the S/MIME certificate",
|
"Renew the S/MIME certificate",
|
||||||
`The certificate expired. Issue a fresh one and update the SMIMEA record:<br>
|
`The certificate expired. Issue a fresh one and update the SMIMEA record:<br>
|
||||||
<pre>openssl req -new -key user.key -subj "/emailAddress=user@example.org" -out user.csr
|
<pre>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</pre>
|
openssl x509 -in user.crt -outform DER | xxd -p -c256 > smimea.hex</pre>
|
||||||
Splice the hex payload into the SMIMEA RDATA.`)
|
Splice the hex payload into the SMIMEA RDATA.`)
|
||||||
|
|
||||||
pick(RuleSMIMEANoEmailProtect,
|
pick(CodeSMIMEANoEmailProtection,
|
||||||
"Add the emailProtection EKU",
|
"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).<br><br>
|
`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).<br><br>
|
||||||
In your <code>openssl.cnf</code>:<br>
|
In your <code>openssl.cnf</code>:<br>
|
||||||
|
|
@ -405,23 +350,35 @@ extendedKeyUsage = emailProtection
|
||||||
keyUsage = digitalSignature, keyEncipherment</pre>
|
keyUsage = digitalSignature, keyEncipherment</pre>
|
||||||
Re-issue the certificate, then update the SMIMEA record.`)
|
Re-issue the certificate, then update the SMIMEA record.`)
|
||||||
|
|
||||||
pick(RuleSMIMEAWeakSigAlgorithm,
|
pick(CodeSMIMEAWeakSignatureAlg,
|
||||||
"Re-issue with a strong signature algorithm",
|
"Re-issue with a strong signature algorithm",
|
||||||
`MD5 and SHA-1 based signatures are collision-vulnerable and will be rejected by modern mail agents.<br><br>
|
`MD5 and SHA-1 based signatures are collision-vulnerable and will be rejected by modern mail agents.<br><br>
|
||||||
Use at least SHA-256 when issuing:<br>
|
Use at least SHA-256 when issuing:<br>
|
||||||
<pre>openssl x509 -req -sha256 -in user.csr -CA ca.pem -CAkey ca.key -out user.crt</pre>`)
|
<pre>openssl x509 -req -sha256 -in user.csr -CA ca.pem -CAkey ca.key -out user.crt</pre>`)
|
||||||
|
|
||||||
pick(RuleSMIMEABadUsage,
|
pick(CodeSMIMEABadUsage,
|
||||||
"Pick a valid SMIMEA usage",
|
"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, <strong>3 (DANE-EE)</strong> is the right choice: it tells verifiers the record carries the exact certificate to trust and no chain validation is required.`)
|
`SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA) or 3 (DANE-EE). For self-hosted end-entity certificates, <strong>3 (DANE-EE)</strong> 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",
|
"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.`)
|
`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
|
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 {
|
func smimeaUsageName(u uint8) string {
|
||||||
switch u {
|
switch u {
|
||||||
case 0:
|
case 0:
|
||||||
|
|
@ -692,7 +649,6 @@ td.sev-info { color: #1e40af; font-weight: 600; }
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
{{if .HasStates}}
|
|
||||||
<h2>Findings {{if .CritCount}}<span class="badge fail">{{.CritCount}} crit</span>{{end}}
|
<h2>Findings {{if .CritCount}}<span class="badge fail">{{.CritCount}} crit</span>{{end}}
|
||||||
{{if .WarnCount}}<span class="badge warn">{{.WarnCount}} warn</span>{{end}}
|
{{if .WarnCount}}<span class="badge warn">{{.WarnCount}} warn</span>{{end}}
|
||||||
{{if .InfoCount}}<span class="badge info">{{.InfoCount}} info</span>{{end}}</h2>
|
{{if .InfoCount}}<span class="badge info">{{.InfoCount}} info</span>{{end}}</h2>
|
||||||
|
|
@ -711,7 +667,6 @@ td.sev-info { color: #1e40af; font-weight: 600; }
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="findings-empty">No issues detected.</p>
|
<p class="findings-empty">No issues detected.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
|
||||||
<p class="note" style="margin-top:.6rem">Collected at {{.CollectedAt}}</p>
|
<p class="note" style="margin-top:.6rem">Collected at {{.CollectedAt}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
169
checker/rule.go
169
checker/rule.go
|
|
@ -7,36 +7,22 @@ import (
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// issue is a rule-internal description of a failed test. Rules return a
|
// Rule returns the single aggregation rule for this checker. It folds
|
||||||
// slice of issues from their check func; Evaluate converts them to
|
// every finding produced by Collect into a CheckState whose status is
|
||||||
// sdk.CheckState.
|
// the worst severity seen.
|
||||||
type issue struct {
|
func Rule() sdk.CheckRule {
|
||||||
Severity sdk.Status // StatusInfo / StatusWarn / StatusCrit
|
return &emailKeyRule{}
|
||||||
Message string
|
|
||||||
Hint string // remediation hint; surfaced as Meta["hint"]
|
|
||||||
Subject string // optional; overrides default data.QueriedOwner
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ruleFunc consumes the facts + runtime options and returns zero or more
|
type emailKeyRule struct{}
|
||||||
// issues. No issues means the test passed.
|
|
||||||
type ruleFunc func(d *EmailKeyData, opts sdk.CheckerOptions) []issue
|
|
||||||
|
|
||||||
// rule is a data-driven CheckRule. All per-test rules share this type;
|
func (r *emailKeyRule) Name() string { return "openpgpkey_smimea_check" }
|
||||||
// only name / description / applicable kinds / options / check differ.
|
|
||||||
type rule struct {
|
func (r *emailKeyRule) Description() string {
|
||||||
name 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."
|
||||||
description string
|
|
||||||
okMessage string // message for StatusOK returns
|
|
||||||
kinds []string // applicable kinds; empty = both
|
|
||||||
options sdk.CheckerOptionsDocumentation // per-rule options
|
|
||||||
check ruleFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *rule) Name() string { return r.name }
|
func (r *emailKeyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
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 {
|
|
||||||
var data EmailKeyData
|
var data EmailKeyData
|
||||||
if err := obs.Get(ctx, ObservationKey, &data); err != nil {
|
if err := obs.Get(ctx, ObservationKey, &data); err != nil {
|
||||||
return []sdk.CheckState{{
|
return []sdk.CheckState{{
|
||||||
|
|
@ -45,65 +31,84 @@ func (r *rule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk
|
||||||
Code: "openpgpkey_observation_error",
|
Code: "openpgpkey_observation_error",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
return []sdk.CheckState{evaluate(&data)}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rules returns the full set of per-test rules for this checker.
|
// evaluate folds findings into a CheckState. The status is the highest
|
||||||
func Rules() []sdk.CheckRule {
|
// severity observed: any Crit makes the whole result Crit, any Warn
|
||||||
out := make([]sdk.CheckRule, len(allRules))
|
// makes it Warn, otherwise Info/OK.
|
||||||
for i := range allRules {
|
func evaluate(data *EmailKeyData) sdk.CheckState {
|
||||||
out[i] = allRules[i]
|
var crit, warn, info int
|
||||||
}
|
var firstCrit, firstWarn, firstInfo string
|
||||||
return out
|
for _, f := range data.Findings {
|
||||||
}
|
switch f.Severity {
|
||||||
|
case SeverityCrit:
|
||||||
func containsString(hay []string, needle string) bool {
|
crit++
|
||||||
for _, v := range hay {
|
if firstCrit == "" {
|
||||||
if v == needle {
|
firstCrit = f.Message
|
||||||
return true
|
}
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Name> 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 <username@…>.",
|
|
||||||
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 <username>@.",
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
@ -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 <fpr> | base64` and paste the result; do not armor the key.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPPrimaryRevoked(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil || !d.OpenPGP.Revoked {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []issue{{
|
|
||||||
Severity: sdk.StatusCrit,
|
|
||||||
Message: "The OpenPGP primary key carries a revocation signature. Consumers will refuse to encrypt to it.",
|
|
||||||
Hint: "Publish a fresh, non-revoked key at this name, or withdraw the OPENPGPKEY record entirely.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPPrimaryExpired(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil || d.OpenPGP.ExpiresAt.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !d.OpenPGP.ExpiresAt.Before(time.Now()) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []issue{{
|
|
||||||
Severity: sdk.StatusCrit,
|
|
||||||
Message: fmt.Sprintf("The OpenPGP primary key expired on %s.", d.OpenPGP.ExpiresAt.Format(time.RFC3339)),
|
|
||||||
Hint: "Extend the key's expiry (`gpg --edit-key <fpr>` → `expire`) or issue a new key and republish the OPENPGPKEY record.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPPrimaryExpiring(d *EmailKeyData, opts sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil || d.OpenPGP.ExpiresAt.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
warnDays := sdk.GetIntOption(opts, OptionCertExpiryWarnDays, 30)
|
|
||||||
if warnDays <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
window := time.Duration(warnDays) * 24 * time.Hour
|
|
||||||
exp := d.OpenPGP.ExpiresAt
|
|
||||||
if exp.Before(now) || exp.Sub(now) >= window {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []issue{{
|
|
||||||
Severity: sdk.StatusWarn,
|
|
||||||
Message: fmt.Sprintf("The OpenPGP primary key expires on %s.", exp.Format(time.RFC3339)),
|
|
||||||
Hint: "Extend the key's expiry before it lapses, then re-export and republish.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPWeakAlgorithm(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []issue
|
|
||||||
if isWeakPGPAlgorithm(d.OpenPGP.PrimaryAlgorithm) {
|
|
||||||
out = append(out, issue{
|
|
||||||
Severity: sdk.StatusWarn,
|
|
||||||
Message: fmt.Sprintf("Primary key uses %s, which modern OpenPGP stacks are phasing out.", d.OpenPGP.PrimaryAlgorithm),
|
|
||||||
Hint: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.",
|
|
||||||
Subject: subkeySubject(d.QueriedOwner, "primary"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for i, sk := range d.OpenPGP.Subkeys {
|
|
||||||
if isWeakPGPAlgorithm(sk.Algorithm) {
|
|
||||||
out = append(out, issue{
|
|
||||||
Severity: sdk.StatusWarn,
|
|
||||||
Message: fmt.Sprintf("Subkey #%d uses %s, which modern OpenPGP stacks are phasing out.", i+1, sk.Algorithm),
|
|
||||||
Hint: "Migrate to RSA-3072+, ECDSA, or Ed25519/Curve25519.",
|
|
||||||
Subject: subkeySubject(d.QueriedOwner, fmt.Sprintf("subkey-%d", i+1)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPWeakKeySize(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []issue
|
|
||||||
if iss := rsaKeySizeIssue(d.OpenPGP.PrimaryAlgorithm, d.OpenPGP.PrimaryBits, "OpenPGP primary"); iss != nil {
|
|
||||||
iss.Subject = subkeySubject(d.QueriedOwner, "primary")
|
|
||||||
out = append(out, *iss)
|
|
||||||
}
|
|
||||||
for i, sk := range d.OpenPGP.Subkeys {
|
|
||||||
if iss := rsaKeySizeIssue(sk.Algorithm, sk.Bits, fmt.Sprintf("OpenPGP subkey #%d", i+1)); iss != nil {
|
|
||||||
iss.Subject = subkeySubject(d.QueriedOwner, fmt.Sprintf("subkey-%d", i+1))
|
|
||||||
out = append(out, *iss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPNoEncryption(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil || d.OpenPGP.ParseError != "" || d.OpenPGP.HasEncryptionCapability {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []issue{{
|
|
||||||
Severity: sdk.StatusCrit,
|
|
||||||
Message: "No active (non-revoked, non-expired) key in the entity advertises encryption capability. The record is useless for email encryption.",
|
|
||||||
Hint: "Generate an encryption subkey (`gpg --edit-key <fpr>` → `addkey`) and re-export.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPNoIdentity(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil || d.OpenPGP.ParseError != "" || len(d.OpenPGP.UIDs) > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []issue{{
|
|
||||||
Severity: sdk.StatusWarn,
|
|
||||||
Message: "The OpenPGP key has no self-signed User ID. Most clients require at least one identity to bind the key to an email address.",
|
|
||||||
Hint: "Add a UID containing the user's email (e.g. `gpg --edit-key <fpr>` → `adduid`) and re-export.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPUIDMismatch(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil || d.OpenPGP.MatchesUsername == nil || *d.OpenPGP.MatchesUsername {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []issue{{
|
|
||||||
Severity: sdk.StatusInfo,
|
|
||||||
Message: fmt.Sprintf("None of the OpenPGP UIDs reference <%s@…>.", d.Username),
|
|
||||||
Hint: "Add a UID bound to the email address that the record attests to.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPMultipleEntities(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil || d.OpenPGP.EntityCount <= 1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []issue{{
|
|
||||||
Severity: sdk.StatusWarn,
|
|
||||||
Message: fmt.Sprintf("The record contains %d OpenPGP entities; RFC 7929 recommends a single entity per OPENPGPKEY record.", d.OpenPGP.EntityCount),
|
|
||||||
Hint: "Split each user's key into its own OPENPGPKEY RR.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPGPRecordTooLarge(d *EmailKeyData, _ sdk.CheckerOptions) []issue {
|
|
||||||
if d.OpenPGP == nil || d.OpenPGP.RawSize <= pgpMaxRecordBytes {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []issue{{
|
|
||||||
Severity: sdk.StatusWarn,
|
|
||||||
Message: fmt.Sprintf("The OpenPGP key packet is %d bytes. Large records force every resolver to fall back to TCP, slowing down the DANE lookup.", d.OpenPGP.RawSize),
|
|
||||||
Hint: "Publish only the minimum key material needed for email encryption (primary + encryption subkey) and strip image UIDs / extra attributes before export.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isWeakPGPAlgorithm(name string) bool {
|
|
||||||
return name == "DSA" || name == "ElGamal"
|
|
||||||
}
|
|
||||||
|
|
||||||
// rsaKeySizeIssue returns a non-nil *issue when the given RSA key is
|
|
||||||
// below NIST's deprecation (2048) or recommendation (3072) thresholds.
|
|
||||||
// Returns nil for non-RSA algorithms or when bits is 0 (unknown).
|
|
||||||
func rsaKeySizeIssue(algorithm string, bits int, label string) *issue {
|
|
||||||
if !strings.EqualFold(algorithm, "RSA") || bits == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if bits < 2048 {
|
|
||||||
return &issue{
|
|
||||||
Severity: sdk.StatusCrit,
|
|
||||||
Message: fmt.Sprintf("%s RSA key of %d bits is considered broken. NIST SP 800-131A deprecates anything below 2048 bits.", label, bits),
|
|
||||||
Hint: "Generate a fresh RSA-3072/4096 or Ed25519/Curve25519 key and republish.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bits < 3072 {
|
|
||||||
return &issue{
|
|
||||||
Severity: sdk.StatusWarn,
|
|
||||||
Message: fmt.Sprintf("%s RSA-%d is aging; NIST recommends at least 3072 bits for new deployments.", label, bits),
|
|
||||||
Hint: "Plan a migration to RSA-3072/4096 or Ed25519/Curve25519 at the next key rotation.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func subkeySubject(owner, label string) string {
|
|
||||||
if owner == "" {
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
return owner + " [" + label + "]"
|
|
||||||
}
|
|
||||||
|
|
@ -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:<user>@<domain>`.",
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
checker/types.go
122
checker/types.go
|
|
@ -1,8 +1,8 @@
|
||||||
// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for
|
// Package checker implements the OPENPGPKEY/SMIMEA DANE checker for
|
||||||
// happyDomain. It gathers the facts published by a zone for an
|
// happyDomain. It runs a comprehensive testsuite on the DNS-published
|
||||||
// abstract.OpenPGP or abstract.SMimeCert service (DNS lookup, DNSSEC
|
// OpenPGP key (RFC 7929) or S/MIME certificate (RFC 8162) corresponding
|
||||||
// flag, parsed OpenPGP key, parsed X.509 certificate) and lets a
|
// to an abstract.OpenPGP or abstract.SMimeCert service, and turns the
|
||||||
// family of per-test rules judge those facts.
|
// results into structured findings + a remediation-oriented HTML report.
|
||||||
package checker
|
package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -25,9 +25,70 @@ const (
|
||||||
DANEOwnerHashSize = 28 // bytes of SHA-256 kept as the owner prefix
|
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.
|
// 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 {
|
type EmailKeyData struct {
|
||||||
// Kind is "openpgpkey" or "smimea".
|
// Kind is "openpgpkey" or "smimea".
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
|
|
@ -43,8 +104,8 @@ type EmailKeyData struct {
|
||||||
// the username-hash-prefix verification is skipped.
|
// the username-hash-prefix verification is skipped.
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
|
|
||||||
// ExpectedOwner is the FQDN at which the record should be published,
|
// ExpectedOwner is the FQDN at which the record should be
|
||||||
// per RFC 7929 / RFC 8162.
|
// published, per RFC 7929 / RFC 8162.
|
||||||
ExpectedOwner string `json:"expected_owner,omitempty"`
|
ExpectedOwner string `json:"expected_owner,omitempty"`
|
||||||
|
|
||||||
// QueriedOwner is the FQDN actually queried (may differ from
|
// 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 is the DNS server that answered the lookup.
|
||||||
Resolver string `json:"resolver,omitempty"`
|
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
|
// DNSSECSecure is true when the validating resolver set the AD flag
|
||||||
// on the answer. Nil means the lookup did not complete.
|
// on the answer. Nil means the lookup did not complete.
|
||||||
DNSSECSecure *bool `json:"dnssec_secure,omitempty"`
|
DNSSECSecure *bool `json:"dnssec_secure,omitempty"`
|
||||||
|
|
||||||
// DNSRecordMatchesService is the result of comparing the DNS-returned
|
|
||||||
// record bytes against the service-body bytes. Nil when the
|
|
||||||
// comparison could not run (DNS failed, or the service body has no
|
|
||||||
// record to compare against).
|
|
||||||
DNSRecordMatchesService *bool `json:"dns_record_matches_service,omitempty"`
|
|
||||||
|
|
||||||
// ObservedOwnerPrefix is the hash-shaped first label extracted from
|
|
||||||
// QueriedOwner (<hex>._openpgpkey.<…> / <hex>._smimecert.<…>), or
|
|
||||||
// empty when the owner does not follow that shape.
|
|
||||||
ObservedOwnerPrefix string `json:"observed_owner_prefix,omitempty"`
|
|
||||||
|
|
||||||
// ExpectedOwnerPrefix is hex(sha256(Username))[:28]. Empty when
|
|
||||||
// Username is empty.
|
|
||||||
ExpectedOwnerPrefix string `json:"expected_owner_prefix,omitempty"`
|
|
||||||
|
|
||||||
// RecordCount is the number of records returned at QueriedOwner.
|
// RecordCount is the number of records returned at QueriedOwner.
|
||||||
RecordCount int `json:"record_count"`
|
RecordCount int `json:"record_count"`
|
||||||
|
|
||||||
|
|
@ -90,17 +128,13 @@ type EmailKeyData struct {
|
||||||
// SMIMEA is populated for kind=smimea.
|
// SMIMEA is populated for kind=smimea.
|
||||||
SMIMEA *SMIMEAInfo `json:"smimea,omitempty"`
|
SMIMEA *SMIMEAInfo `json:"smimea,omitempty"`
|
||||||
|
|
||||||
|
Findings []Finding `json:"findings"`
|
||||||
|
|
||||||
CollectedAt time.Time `json:"collected_at"`
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenPGPInfo summarises the OpenPGP key observed in the record.
|
// OpenPGPInfo summarises the OpenPGP key observed in the record.
|
||||||
type OpenPGPInfo struct {
|
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 is the length in bytes of the transport key material.
|
||||||
RawSize int `json:"raw_size"`
|
RawSize int `json:"raw_size"`
|
||||||
|
|
||||||
|
|
@ -130,11 +164,6 @@ type OpenPGPInfo struct {
|
||||||
// Revoked is true when the primary key carries a revocation signature.
|
// Revoked is true when the primary key carries a revocation signature.
|
||||||
Revoked bool `json:"revoked,omitempty"`
|
Revoked bool `json:"revoked,omitempty"`
|
||||||
|
|
||||||
// MatchesUsername is nil when the check was not run (no UIDs or no
|
|
||||||
// username), true when at least one UID references <username@…>,
|
|
||||||
// false otherwise.
|
|
||||||
MatchesUsername *bool `json:"matches_username,omitempty"`
|
|
||||||
|
|
||||||
// Subkeys describes the subordinate keys.
|
// Subkeys describes the subordinate keys.
|
||||||
Subkeys []SubkeyInfo `json:"subkeys,omitempty"`
|
Subkeys []SubkeyInfo `json:"subkeys,omitempty"`
|
||||||
|
|
||||||
|
|
@ -161,10 +190,6 @@ type SubkeyInfo struct {
|
||||||
|
|
||||||
// SMIMEAInfo summarises the S/MIME record.
|
// SMIMEAInfo summarises the S/MIME record.
|
||||||
type SMIMEAInfo struct {
|
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"`
|
Usage uint8 `json:"usage"`
|
||||||
Selector uint8 `json:"selector"`
|
Selector uint8 `json:"selector"`
|
||||||
MatchingType uint8 `json:"matching_type"`
|
MatchingType uint8 `json:"matching_type"`
|
||||||
|
|
@ -197,11 +222,6 @@ type CertInfo struct {
|
||||||
HasKeyEncipherment bool `json:"has_key_encipherment,omitempty"`
|
HasKeyEncipherment bool `json:"has_key_encipherment,omitempty"`
|
||||||
IsSelfSigned bool `json:"is_self_signed,omitempty"`
|
IsSelfSigned bool `json:"is_self_signed,omitempty"`
|
||||||
IsCA bool `json:"is_ca,omitempty"`
|
IsCA bool `json:"is_ca,omitempty"`
|
||||||
|
|
||||||
// EmailMatchesUsername is nil when the check was not run (no
|
|
||||||
// username or no email SAN on the certificate), true when at least
|
|
||||||
// one SAN begins with "<username>@", false otherwise.
|
|
||||||
EmailMatchesUsername *bool `json:"email_matches_username,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PubKeyInfo summarises an SPKI-only SMIMEA record.
|
// PubKeyInfo summarises an SPKI-only SMIMEA record.
|
||||||
|
|
|
||||||
18
go.mod
18
go.mod
|
|
@ -3,17 +3,17 @@ module git.happydns.org/checker-email-keys
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.happydns.org/checker-sdk-go v1.7.0
|
git.happydns.org/checker-sdk-go v1.1.0
|
||||||
github.com/ProtonMail/go-crypto v1.4.1
|
github.com/ProtonMail/go-crypto v1.1.0-alpha.0
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.3.7 // indirect
|
||||||
golang.org/x/crypto v0.51.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/mod v0.36.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/net v0.54.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
36
go.sum
36
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.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y=
|
||||||
git.happydns.org/checker-sdk-go v1.7.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
github.com/ProtonMail/go-crypto v1.1.0-alpha.0 h1:nHGfwXmFvJrSR9xu8qL7BkO4DqTHXE9N5vPhgY2I+j0=
|
||||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
github.com/ProtonMail/go-crypto v1.1.0-alpha.0/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
|
||||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
|
|
|
||||||
6
main.go
6
main.go
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
emailkeys "git.happydns.org/checker-email-keys/checker"
|
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:
|
// Version is the standalone binary's version. Override with:
|
||||||
|
|
@ -20,8 +20,8 @@ func main() {
|
||||||
|
|
||||||
emailkeys.Version = Version
|
emailkeys.Version = Version
|
||||||
|
|
||||||
srv := server.New(emailkeys.Provider())
|
server := sdk.NewServer(emailkeys.Provider())
|
||||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||||
log.Fatalf("server error: %v", err)
|
log.Fatalf("server error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,5 @@ var Version = "custom-build"
|
||||||
// the .so file.
|
// the .so file.
|
||||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
emailkeys.Version = Version
|
emailkeys.Version = Version
|
||||||
prvd := emailkeys.Provider()
|
return emailkeys.Definition(), emailkeys.Provider(), nil
|
||||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue