Differential fuzzer for the V2 bundle verifiers¶
run.mjs feeds the same mutated V2 signed bundle to the four SDK verifiers
(verifySignedBundleV2 in TypeScript, verify_signed_bundle_v2 in Python,
VerifySignedBundleV2At in Go, verify_signed_bundle_v2_at in Rust), under one explicit policy and one
verification instant. A disagreement, a verdict that contradicts what the shared vectors declare, a key
reorder that changes a verdict, and any error, crash or timeout is a finding. The security round closed by
v2.10.0 was almost entirely disagreements between the four verifiers; this tool is how they are found
before a release. The driver is the module fuzz.mjs, which run.mjs and the self-tests share.
Setup¶
Every SDK is run from this checkout, never from a published package:
(cd sdks/typescript && npm install && npm run build) # shims/ts imports sdks/typescript/dist
pip install -e sdks/python # dependencies of shims/py (pynacl, pqcrypto>=1.0)
Go (version from sdks/go/go.mod) and a stable Rust toolchain must be on PATH. The driver builds
shims/go (a module that replaces the SDK with ../../../../sdks/go) with -mod=readonly, and
shims/rust (a path dependency on ../../../../sdks/rust) with --locked, both into the run's own private
temporary directory (Cargo's CARGO_TARGET_DIR included), so concurrent runs never share a binary; the Rust
shim is therefore built from scratch on every run. Nothing changes a manifest or a lockfile. With
--offline, Go runs with GOPROXY=off and Cargo with --offline, so the module and crate caches must
already hold the dependencies. The build has its own deadline.
The report's build.artifacts records what the run measured, by content: the four shims, the two built
binaries (their hashes, never their ephemeral paths), the TypeScript src and dist trees, its
package-lock.json and the directory of each direct runtime dependency Node resolves for it, the Python SDK
source and the interpreter with five distribution versions, and the Go and Rust SDK sources and the Rust lock.
inputs records the driver (fuzz.mjs, run.mjs) and the fixtures. Both are checked again at the end of the
run; a change is exit 3. provenance_limits states what the hashes do not prove: that dist was built from
that src, and the transitive dependencies, are not verified.
Usage¶
node tools/difffuzz/run.mjs # 200 iterations, seed 1
node tools/difffuzz/run.mjs --iterations 2000 --seed 7 --report /tmp/difffuzz.json
node tools/difffuzz/run.mjs --replay tools/difffuzz/findings/<file>.json
node --test tools/difffuzz/difffuzz.test.mjs # self-tests of the harness (no SDK)
node --test tools/difffuzz/real-shims.test.mjs # bounded diagnostic tests with all four SDKs
| Option | Default | Meaning |
|---|---|---|
--iterations N |
200 |
mutants to generate (0 to 1,000,000) |
--seed S |
1 |
PRNG seed; the same seed and fixtures give the same mutants |
--modes a,b |
default,hybrid_required |
policies every seed and mutant runs under (see Policies) |
--timeout MS |
60000 |
deadline of one batch process (--timeout-ms is the same) |
--python PATH |
$DIFFFUZZ_PYTHON or python3 |
interpreter with the Python SDK's dependencies |
--out DIR |
tools/difffuzz/findings |
where findings are written |
--report FILE |
none | the full run report (JSON): an incomplete report is written first and replaced on every path, a usage error included; a missing, option-like or repeated value writes nothing |
--offline |
off | build from local caches only |
--build-timeout-ms MS |
600000 |
one deadline for building the shims |
--batch-cases N, --batch-bytes B, --case-bytes B |
64, 16 MiB, 4 MiB |
cases per batch, the exact UTF-8 size of a whole request, raw bytes of one input. A seed that does not fit is exit 3; a mutant that does not fit is skipped and counted |
--stdout-bytes B |
8 MiB | output limit of one batch process |
--max-findings N |
50 |
findings recorded; once reached, no further batch runs (baseline included) and the run cannot pass |
--max-retries N |
200 |
single-case reruns that isolate failed batches; exhausting it is exit 3 |
--minimize-steps N, --minimize-total N |
24, 96 |
minimization budget per finding and per run |
Every value is checked before anything is built: an unknown, repeated, missing or out-of-range argument is exit 2. Exit codes: 0 no finding, no failed batch and a complete baseline; 1 at least one finding, a failed batch or an incomplete baseline; 2 usage; 3 infrastructure or harness fault (a build failure, inputs that changed during the run, a shim that cannot start or answers outside the protocol or as unsupported, in a batch or alone, an exhausted retry budget, no seed declared valid in the first mode while there are iterations). The commands are always exactly the four shims.
Policies and time¶
Every request carries the policy and the instant explicitly: now_ms is 2026-10-22T00:00:00Z, the instant of
the external conformance kit, so a verdict never depends on the day it runs. default is the vectors'
default_verifier_policy (the TypeScript DEFAULT_VERIFIER_POLICY) for every SDK; any other mode name is
applied to default_mode and to every risk tier, as the vectors build each mode's policy. A seed's
verifier_policy_overrides replace those fields in every mode. (Before this protocol, default was each
SDK's own default policy and every shim read its own system clock.)
Seeds and expectations¶
Seeds are the bundles of tests/interop/v2/bundle_vectors.json, with what its _description declares:
signed_bundle verifies; every must_reject mutation of it and every must_reject_bundles entry is
rejected whatever the mode; every must_accept_bundles entry verifies in every mode;
classical_only_routine_bundle gets verified_by_mode (it declares no verdict for default). The default
policy resolves to one of the four modes, so "every" and "any" mode cover it. A verdict that contradicts a
declared expectation is an expectation finding even when the four SDKs agree: agreement is never the
oracle. If such an expectation turns out to contradict the specification itself, keep the reproducer and stop
using that interpretation; do not replace it with the majority. The signed bundles of
tests/interop/v2/cross_sdk_vectors.json and tests/conformance/examples/ declare nothing and are
differential-only.
Every odd iteration (so at least half of them, rounded up) starts from a seed declared valid in the first mode, picked at random; the others start from any seed. With iterations and no such seed, the run stops with exit 3.
Mutators¶
Each iteration applies one mutation (70%) or two (30%) at a random slot: delete a key, set a value to
null, swap a value's type, swap two sibling values, flip one character of a base64 string, replace an
enumerated string with a prototype key (constructor, __proto__, toString, valueOf, ...), rename a key
to a prototype key, add an unknown key, duplicate an artifact (or its payload, payload_hash or
composite_sig) into another slot, rewrite a number as 1.0, 1e2, -0, 0.1, 2^64, 1e400, ...,
rewrite an RFC 3339 timestamp with another offset, malformed or far in the past or future, an oversized string
(64 KiB or 1 MiB), a 100-deep nested value, and a recursive key reorder, which must never change a verdict:
each SDK's result is compared with its own baseline for the seed. Numbers that JavaScript cannot represent are
spliced into the serialized text verbatim, so every parser sees the same bytes. The key reorder keeps an own
__proto__ key (it used to drop it).
Judgement¶
Each shim answers one of: verdict (verified and errors), parse_rejected (its JSON parser refused the
document), error (the SDK threw or panicked) or unsupported; the driver adds crash (an exit other than 0
or a signal), timeout, output_limit and protocol (an answer outside the protocol). The SDKs are compared
on acceptance: a verdict true accepts; a verdict false or a parser rejection rejects, as before; any other
category is never a vote. A batch whose process fails is recorded and each of its cases is rerun alone to find
the culprit; the failed batch keeps the run from passing whatever the reruns give, and a failure that no single
case reproduces is also a finding (batch_failure_not_reproduced). Every shim run of a batch is awaited, whichever
fails first, before the run ends.
Findings¶
Every finding is written to findings/<seed>-<iteration>-<mode>-<n>.json (the directory is git-ignored
except for .gitkeep): its classes, the seed, the mode, the explicit policy and now_ms, the mutations,
every SDK's outcome, and the exact input bytes (input_b64, input_sha256). A finding is a lead, not yet a
bug. Replay it first with --replay: the recorded bytes (and the minimized input, when there is one) run through
the four shims at the finding's own now_ms and under its own policy, in one batch with the finding's own
recorded control: the original bytes of the unmutated seed (hash, policy, declared expectation and baseline
outcomes), never the current fixture of that name. The finding is checked first, fully typed: its kind, a set of
known classes equal to the classes its own outcomes give, four typed outcomes, its hashes and its control. The
current build, driver, fixtures and seed are compared with the recorded ones and every difference is reported as
a provenance difference. Replay exit codes: 0 the recorded decisions reproduce and the control reproduces its
baseline, 1 they do not reproduce, 3 the finding, the control or the infrastructure does not allow a judgement.
Disagreements and errors or crashes of a mutant are then minimized within a budget (an expectation finding is
an unmutated seed already), and only against a positive control: the unmutated seed passed its own baseline in
that mode, verified true by the four SDKs (a seed correctly rejected is no control). The differences from the
seed are reduced while every SDK keeps the same decision and the seed, run alongside, keeps its baseline. Raw tokens such as 1e400 are kept as bytes; the text is
never re-parsed. The result is minimal for that search and budget, not globally; key-order findings and
timeouts are not minimized.
Decide which SDK is right from the normative requirements (spec/VERIFICATION.md, the canonicalization
profile) and the shared fixtures (tests/interop/v2/). When they do not settle the case, it is an open
question for the specification, not a fix to pick: record it rather than adopting the fail-closed verdict by
default. Fix the wrong SDK test-first with a unit test in that SDK, and never weaken a verifier to reach
agreement.
Shims¶
--batch is the driver's protocol (dcp-difffuzz/3), one finite process per batch, run through the external
conformance kit's process runner (runProcess in tools/conformance/external.js: one deadline, its own
process group, SIGTERM then SIGKILL, bounded output and cleanup). The request on stdin is
{protocol, run_id, nonce, now_ms, cases: [{id, policy, bundle_b64}]} with random ids and no expectation or
seed name; the response on stdout is one JSON document {protocol, run_id, nonce, results} with exactly one
result per case, then exit 0. A malformed request exits 2. The Go shim writes nil errors as []; the Rust
shim decodes base64 with std only.
In this internal harness protocol, batch verdict diagnostics keep at most 8 errors, each a literal prefix
of at most 256 Unicode code points. A shortened list or string carries truncated with original_count (original
count), kept (retained count), original_code_points (original total) and sha256 (digest of the original list).
Ordinary diagnostics within those bounds remain unchanged. Malformed diagnostic types remain protocol
failures; they never become valid rejections. This does not change DCP wire formats or verifier decisions.
The digest hashes each original diagnostic in order as its 4-byte big-endian UTF-8 byte length followed by its UTF-8 bytes. TypeScript and Python replace lone surrogates with U+FFFD for this hash; the diagnostic prefix itself is not normalized. Go replaces each invalid UTF-8 byte with U+FFFD for the hash; Rust strings are valid UTF-8. Diagnostic text differs between SDKs, so these digests identify diagnostics from a particular shim, not cross-SDK equivalence.
The driver checks the bounds and marker coherence, including evidence that something was actually omitted.
The marker travels with recorded outcomes. It cannot authenticate the original text without that text, or
prove that a shim did not silently shorten an otherwise valid short message. Historical findings from
dcp-difffuzz/2 remain replayable, including their original unbounded diagnostics; live responses must use
dcp-difffuzz/3 and meet its bounds. Manual and loop output retain their previous behavior.
stats.truncated_diagnostic_observations counts bounded diagnostic observations per SDK, including repeated observations
from isolation retries and minimization; it is not a count of distinct inputs. The caps limit diagnostic
transport, not SDK computation or internal memory. Existing output limits still apply: a smaller configured
--stdout-bytes may legitimately yield output_limit, and a failed batch still prevents a passing run.
Without --batch, each shim keeps its earlier form for manual use: one document on stdin (or one per line
with --loop), {"valid": bool, "errors": [string]} per document, an optional mode as first argument, each
SDK's own default policy and the system clock.
CI¶
.github/workflows/difffuzz.yml runs the self-tests and the bounded four-SDK diagnostic tests, then 300 iterations on every pull request, 3000
nightly, and takes iterations and seed inputs on workflow_dispatch. Findings and the report are uploaded
as the difffuzz-findings artifact when the job fails.