Compare commits

...

4 commits

Author SHA1 Message Date
d1a457aead Update go mod
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-10 19:38:54 +08:00
dced61103b Add CI/CD pipeline
Some checks failed
continuous-integration/drone/push Build is failing
2026-05-10 19:07:00 +08:00
01b475af4b Include rules section 2026-04-30 09:17:40 +07:00
59d66153ee checker: join recorded owner Hdr.Name to parent FQDN 2026-04-30 09:17:33 +07:00
6 changed files with 274 additions and 76 deletions

22
.drone-manifest.yml Normal file
View file

@ -0,0 +1,22 @@
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 Normal file
View file

@ -0,0 +1,187 @@
---
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

View file

@ -36,53 +36,40 @@ keys it finds. It does **not** cryptographically verify them:
Treat a green report as "the record is well-formed and DNSSEC-signed",
not as "the key is trustworthy".
## Tests run
## Rules
All findings are tagged by severity (`info` / `warn` / `crit`) so the
rule engine can fold them into a single `CheckState`.
### DNS (both record types)
| Code | Severity | What it catches |
| --- | --- | --- |
| `dns_query_failed` | crit | The resolver returned an error or did not answer. |
| `dns_no_record` | crit | The authoritative answer has no record at the expected owner. |
| `dnssec_not_validated` | crit / warn | The validating resolver did not set `AD`. RFC 7929/8162 mandate DNSSEC; the severity is configurable via `requireDNSSEC`. |
| `dns_record_mismatch` | warn | The record returned by DNS differs from the one declared in the service (typically a stale zone on the authoritative servers). |
| `owner_hash_mismatch` | crit | Record owner-name first label is not `sha256(localpart)[:28]`; mail clients will never find it. |
### OpenPGP-specific (RFC 7929)
| Code | Severity | What it catches |
| --- | --- | --- |
| `pgp_parse_error` | crit | Malformed base64 or OpenPGP packet stream. |
| `pgp_no_entity` | crit | Record decoded but carries no valid entity. |
| `pgp_primary_revoked` | crit | Primary key has a revocation signature. |
| `pgp_primary_expired` | crit | Self-signature expired; clients will refuse to encrypt. |
| `pgp_primary_expiring_soon` | warn | Expires within the `certExpiryWarnDays` window (default 30). |
| `pgp_weak_algorithm` | warn | Uses DSA / ElGamal (phase-out). |
| `pgp_weak_key_size` | crit / warn | RSA below 2048 bits is critical, 2048-3071 is a warn. |
| `pgp_no_encryption_subkey` | crit | No active key in the entity advertises encryption capability. |
| `pgp_no_identity` | warn | No self-signed User ID. |
| `pgp_uid_mismatch` | info | None of the UIDs reference `<username@…>`. |
| `pgp_multiple_entities` | warn | Record carries more than one entity (RFC 7929 recommends one). |
| `pgp_record_too_large` | warn | Raw key > 4 KiB; forces UDP→TCP fallback on every lookup. |
### SMIMEA-specific (RFC 8162)
| Code | Severity | What it catches |
| --- | --- | --- |
| `smimea_bad_usage` / `_selector` / `_match_type` | crit | Field outside the allowed range. |
| `smimea_cert_parse_error` | crit | Hex-encoded blob is not a valid X.509 certificate / SPKI. |
| `smimea_cert_expired` / `_not_yet_valid` | crit | `notBefore` / `notAfter` gate the current time out. |
| `smimea_cert_expiring_soon` | warn | Within the `certExpiryWarnDays` window. |
| `smimea_no_email_protection_eku` | crit / warn | Missing `emailProtection` EKU (RFC 8550/8551 agents will reject). |
| `smimea_missing_key_usage` | warn | Neither `digitalSignature` nor `keyEncipherment` key-usage is set. |
| `smimea_email_mismatch` | info | No email SAN starts with `<username>@`. |
| `smimea_weak_signature_algorithm` | crit | MD5 / SHA-1 based signature. |
| `smimea_weak_key_size` | crit / warn | RSA < 2048 / 3072 bits. |
| `smimea_self_signed` | info | Self-signed certificate paired with PKIX-EE usage. |
| `smimea_hash_only` | info | Matching-type 1/2 only carries a digest; certificate can't be inspected. |
| Code | Description | Severity |
|-----------------------------------|---------------------------------------------------------------------------------------------------|---------------------|
| `dns_query_failed` | Verifies that the DNS lookup for the OPENPGPKEY/SMIMEA record succeeds. | CRITICAL |
| `dns_no_record` | Verifies that an OPENPGPKEY/SMIMEA record is published at the expected owner name. | CRITICAL |
| `dns_record_mismatch` | Verifies that the record returned by DNS matches the service-declared record. | WARNING |
| `dnssec_not_validated` | Verifies that the record is authenticated by DNSSEC (AD flag set). | CRITICAL |
| `owner_hash_mismatch` | Verifies that the owner-name first label equals hex(sha256(username))[:28]. | CRITICAL |
| `pgp_parse_error` | Verifies that the OPENPGPKEY record decodes as a valid OpenPGP key. | CRITICAL |
| `pgp_primary_revoked` | Verifies that the OpenPGP primary key carries no revocation signature. | CRITICAL |
| `pgp_primary_expired` | Verifies that the OpenPGP primary key has not passed its self-signature expiry. | CRITICAL |
| `pgp_primary_expiring_soon` | Warns when the OpenPGP primary key expires within the configured window. | WARNING |
| `pgp_weak_algorithm` | Verifies that OpenPGP keys do not use legacy algorithms (DSA/ElGamal). | WARNING |
| `pgp_weak_key_size` | Verifies that OpenPGP RSA keys meet the minimum 2048-bit size (3072+ preferred). | CRITICAL |
| `pgp_no_encryption_subkey` | Verifies that at least one active OpenPGP key advertises encryption capability. | CRITICAL |
| `pgp_no_identity` | Verifies that the OpenPGP key carries at least one self-signed User ID. | WARNING |
| `pgp_uid_mismatch` | Checks that at least one OpenPGP UID references <username@...>. | INFO |
| `pgp_multiple_entities` | Verifies that the record carries a single OpenPGP entity (RFC 7929). | WARNING |
| `pgp_record_too_large` | Verifies that the OPENPGPKEY record stays below 4 KiB to fit typical UDP answers. | WARNING |
| `smimea_bad_usage` | Verifies that the SMIMEA usage field is 0, 1, 2, or 3. | CRITICAL |
| `smimea_bad_selector` | Verifies that the SMIMEA selector field is 0 (Cert) or 1 (SPKI). | CRITICAL |
| `smimea_bad_match_type` | Verifies that the SMIMEA matching type is 0 (Full), 1 (SHA-256), or 2 (SHA-512). | CRITICAL |
| `smimea_cert_parse_error` | Verifies that the SMIMEA record decodes as a valid X.509 certificate or SPKI. | CRITICAL |
| `smimea_cert_not_yet_valid` | Verifies that the S/MIME certificate's NotBefore is in the past. | CRITICAL |
| `smimea_cert_expired` | Verifies that the S/MIME certificate's NotAfter is in the future. | CRITICAL |
| `smimea_cert_expiring_soon` | Warns when the S/MIME certificate expires within the configured window. | WARNING |
| `smimea_no_email_protection_eku` | Verifies that the S/MIME certificate advertises the emailProtection EKU. | CRITICAL |
| `smimea_missing_key_usage` | Verifies that the certificate carries digitalSignature and/or keyEncipherment key usage. | WARNING |
| `smimea_weak_signature_algorithm` | Verifies that the certificate is not signed with a deprecated algorithm (MD2/MD5/SHA-1). | CRITICAL |
| `smimea_weak_key_size` | Verifies that SMIMEA RSA keys meet the minimum 2048-bit size (3072+ preferred). | CRITICAL |
| `smimea_self_signed` | Flags self-signed certificates paired with PKIX-EE (usage 1). | INFO |
| `smimea_email_mismatch` | Checks that at least one email SAN on the certificate begins with <username>@. | INFO |
| `smimea_hash_only` | Notes that SMIMEA matching types 1/2 transport only a digest, preventing certificate inspection. | INFO |
## Options

View file

@ -193,11 +193,13 @@ func computeOwner(body serviceBody, prefix, parent string) (expected, recorded s
// Normalise: no double dots.
expected = strings.Replace(expected, "..", ".", -1)
}
// happyDomain encodes service-embedded record owners relative to the
// parent zone, so we must join with parent before treating as FQDN.
switch {
case body.OpenPGP != nil && body.OpenPGP.Hdr.Name != "":
recorded = dns.Fqdn(body.OpenPGP.Hdr.Name)
recorded = dns.Fqdn(sdk.JoinRelative(body.OpenPGP.Hdr.Name, parent))
case body.SMIMEA != nil && body.SMIMEA.Hdr.Name != "":
recorded = dns.Fqdn(body.SMIMEA.Hdr.Name)
recorded = dns.Fqdn(sdk.JoinRelative(body.SMIMEA.Hdr.Name, parent))
}
return
}

18
go.mod
View file

@ -3,17 +3,17 @@ module git.happydns.org/checker-email-keys
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.5.0
github.com/ProtonMail/go-crypto v1.1.0-alpha.0
git.happydns.org/checker-sdk-go v1.7.0
github.com/ProtonMail/go-crypto v1.4.1
github.com/miekg/dns v1.1.72
)
require (
github.com/cloudflare/circl v1.3.7 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)

36
go.sum
View file

@ -1,22 +1,22 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/ProtonMail/go-crypto v1.1.0-alpha.0 h1:nHGfwXmFvJrSR9xu8qL7BkO4DqTHXE9N5vPhgY2I+j0=
github.com/ProtonMail/go-crypto v1.1.0-alpha.0/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
git.happydns.org/checker-sdk-go v1.7.0 h1:dSgo2js5mfXluLc6x0WWZ0MQULd9XV2GI9z0Usk+Qgw=
git.happydns.org/checker-sdk-go v1.7.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=