Fuzz & property testing for parsers and crypto boundaries¶
secsy-pki ships Go native fuzz tests (go test -fuzz) over every surface that
turns untrusted input into structured data. These are the places an attacker
gets to hand the server arbitrary bytes, so they are exactly where a panic,
unbounded allocation, or nil-dereference would become a denial-of-service or
worse. The fuzzers back the Task 12 hardening invariants with continuous,
adversarial evidence rather than a one-time review.
What is fuzzed¶
| Target | Package | Untrusted surface |
|---|---|---|
FuzzParseAndVerifyCSR |
internal/ca |
PEM/DER PKCS#10 CSR decode + self-signature check — the issuance ingress for both the REST API and ACME |
FuzzParseCertificatePEM |
internal/pki |
PEM → DER X.509 certificate parsing (CA config, chain validation) |
FuzzParseOCSPRequest |
internal/pki |
DER OCSP request parsing — public, unauthenticated (POST body and base64 GET path) |
FuzzParseJWS |
internal/acme |
ACME flattened-JSON JOSE/JWS decode + protected-header extraction + JWK thumbprinting (decodeJWS) |
FuzzACMEPayloads |
internal/acme |
ACME JSON request payloads (newAccount / newOrder / account-update / keyChange) and the base64url→DER CSR (finalize) and certificate (revoke) decodes |
FuzzEnvelopeUnmarshal |
internal/secret |
JSON secret-envelope parsing (version/algorithm/field validation) |
FuzzEnvelopeOpen |
internal/secret |
Full envelope decrypt path: validate → RSA-OAEP unwrap of the DEK → AES-256-GCM open, on adversarial ciphertext material and encryption context |
FuzzEnvelopeOpen exercises the real decrypt path against an in-memory RSA
wrapper that mirrors the production HSM-backed wrapper's RSA-OAEP semantics, so
the crypto boundary is fuzzed with no HSM required — the same way it runs in
CI.
Each target seeds its corpus with both known-good inputs (a genuine CSR, OCSP request, signed JWS, sealed envelope, …) and known-malformed inputs (empty, truncated ASN.1 long-form lengths, wrong PEM block types, undecodable base64, unknown JSON fields, unsupported versions/algorithms). The invariant asserted is uniform: no panic, and never a nil result paired with a nil error.
Running locally¶
One target per invocation is a go test -fuzz limitation, so use the helper
that enumerates all of them:
cd server
# Default: 30s per target.
./scripts/fuzz.sh
# A quick smoke pass (what CI runs).
FUZZTIME=10s ./scripts/fuzz.sh
# A long overnight campaign — the way to actually find deep bugs.
FUZZTIME=30m ./scripts/fuzz.sh
# Drive a single target directly.
./scripts/fuzz.sh ./internal/secret/ FuzzEnvelopeOpen
# …or with raw go:
go test -run '^$' -fuzz='^FuzzEnvelopeOpen$' -fuzztime=5m ./internal/secret/
Running the packages as ordinary tests (no -fuzz) replays just the seed
corpora — fast and deterministic, and it also reproduces any committed crasher:
When a fuzzer finds a crash¶
Go writes the offending input to testdata/fuzz/<Target>/<hash> under the
target's package and prints a go test -run=<Target>/<hash> reproducer.
- Reproduce and fix the underlying parser/crypto bug.
- Commit the
testdata/fuzz/...file. It becomes a permanent regression seed: from then on the plaingo testrun (and the CI seed-corpora step) replays it, so the bug can never silently return.
CI¶
.github/workflows/enterprise-ci.yaml runs a dedicated fuzz-smoke job on
every push/PR to the main branch. It needs no SoftHSM (all targets run
in software), and it:
- replays every seed corpus as unit tests (fails on any committed crasher), then
- runs each fuzz target for a bounded
FUZZTIME(20s) to catch regressions the mutation engine reaches quickly.
The bounded budget keeps CI fast; deep discovery is the job of the longer local campaigns above.