Checks that every TLS client we ship refuses a distrusted CA. It runs once per response, and again every week as a regression check.
Each check runs in a min box, built from the packages users actually get. This directory
has its own minimal.toml, and its [upstream] pins gominimal/pkgs. The clients come
from that commit, the same way a user's project gets them. They do not come from the
checkout this file sits in.
tests/tls-trust/
minimal.toml [upstream] pin + 6 tasks (live / offline x 3 package sets)
run.sh entry point: --mode live|offline --profile <dir> ...
lib/common.sh timeouts, classifiers, openssl-verify helpers, JSON
lib/c-clients.sh curl, wget, openssl, gnutls-cli (+certtool), git
lib/python.sh python3 urllib, pip
lib/httpie.sh httpie
lib/js.sh + probe.mjs node, node --use-system-ca, bun, deno (one probe for all)
lib/java.sh + Probe.java the JDK on PATH
lib/go.sh + probe.go go
lib/rust.sh uv, uv with UV_SYSTEM_CERTS=1
lib/controls.sh SELFTEST and NEGCONTROL
responses/<id>/ one directory per response (the "profile")
targets.txt hosts that must be refused: <host> <group> <label>
positives.txt hosts that must connect
expected.txt verify_at (offline instant), blocklist_rows, expected failure class
chains/bad/<host>.pem the chain each target serves (public certs only)
chains/good/<host>.pem the chain each positive serves
chains/anchors.pem the distrusted roots the bad chains anchor at
chains/SHA256SUMS pins every PEM; chains/README.md cites the source
The directory is responses/, not profiles/, because min treats profiles/<name>/ as
its own profile convention (profile.ncl). A profiles/ here breaks session creation.
From a pkgs checkout, min task run offline -C tests/tls-trust --no-input works as is.
That was tested from a worktree of this branch. min task run uploads only tracked files,
though. So when you edit the harness or a profile, or need a second pin running alongside,
copy this directory into a scratch git repo, commit it, and run from there:
X=$(mktemp -d) && cp -R tests/tls-trust/. "$X" && cd "$X" && git init -q && git add -A && git commit -qm tls-trust
min task run live -C "$X" --config-dir "$(mktemp -d)" --no-input
min task run offline -C "$X" --config-dir "$(mktemp -d)" --no-input
min task run live-alt -C "$X" ... # node-lts + jdk-21
min task run live-jdk27 -C "$X" ... # jdk-27
min task run offline-alt -C "$X" ...
min task run offline-jdk27 -C "$X" ...
# task args: --only REGEX --jobs N --timeout S --details fail|all|none --profile DIR
min task run live -C "$X" --no-input --only '^(curl|go)$' --details allWhy six tasks. Some packages install the same paths. node and node-lts both install
usr/bin/node. jdk-21, jdk-25 and jdk-27 all install usr/lib/jvm. If two of them share
a task, one of them silently wins, and its row reports the wrong runtime. So live and
offline carry everything else plus jdk-25. *-alt carries node-lts and jdk-21.
*-jdk27 carries jdk-27. Each split task restricts itself with --rows.
A full live run takes about 16 s inside the box (about 30 s wall with session setup).
offline takes about 8 s. Probes run 8 at a time (--jobs), and each one has a hard
deadline (--timeout, default 15 s). The single-threaded harness this replaces took 2 to
3 minutes per task.
| mode | what runs | refused | accepted | anything else |
|---|---|---|---|---|
live |
a real handshake and HTTP request per client, for every host in targets + positives | CERT-FAIL (only a certificate-verification error) |
CONNECTED |
NET-ERR (DNS, connect, timeout) or ERR |
offline |
no network. Each client's verifier validates the captured chains at verify_at |
REJECTED (only trust errors: unknown issuer, untrusted, distrusted, blocked) |
ACCEPTED |
ERR (expiry, parse error, crash) |
A live probe that ends in NET-ERR or ERR is retried once, so one network blip does
not turn a weekly run INCONCLUSIVE. A trust verdict is never retried. A probe that hits
the backstop deadline (exit 124 or 137) counts as NET-ERR.
Verdicts:
VULNERABLE: at least one bad target was accepted.FIXED: every bad target was refused, and every positive was accepted.INCONCLUSIVE: anything else. A positive failed, a probe hit a network error, a probe errored, or a client is missing.
N/A (live-only) or N/A (offline-only) means the row has no honest check in that mode.
The table legend says why.
Exit codes:
| code | meaning |
|---|---|
| 0 | clean |
| 1 | INCONCLUSIVE rows, with no VULNERABLE row |
| 2 | at least one VULNERABLE row |
| 3 | the harness is invalid: SELFTEST is not FIXED, or NEGCONTROL is not VULNERABLE |
--json - (every task passes it) prints a === json === block. It holds per-row
client, version, mode, proof, verdict and per-host results. It also holds run metadata:
profile, pinned pkgs commit, timestamp, verify_at, jobs, timeout, runtime, the selftest
and negcontrol verdicts, and the exit code.
- SELFTEST uses curl (live) or
openssl verify(offline) against the box's system bundle, withchains/anchors.pemstripped out. It must readFIXED. If it doesn't, the classifier or the profile is broken, not the packages. - NEGCONTROL uses the same tools against the bundle plus the anchors. It must accept
every bad target, which gives
VULNERABLE. That proves each target is still a working probe: the host is up, the chain still anchors at the profile's roots, and nothing has expired atverify_at. If it readsBROKEN (n/12 accepted), a FIXED anywhere else means nothing. To test this guard,verify_atwas moved to 2031, when every leaf has expired. The run gave exit 3 and NEGCONTROLBROKEN (0/12 accepted).
| row | offline | what it proves |
|---|---|---|
| openssl | path | openssl verify against OpenSSL's default store (OPENSSLDIR, or SSL_CERT_FILE/DIR). Inside the box that store is our package's, never the host's; the header prints openssl version -d. |
| gnutls-cli | path | certtool --verify with no CA argument, so it uses gnutls's system trust and runs gnutls's real chain verifier. |
| java | path | PKIX CertPathBuilder over the default JSSE trust store. It applies blocked.certs and disabledAlgorithms. Its error message hides the reason ("unable to find valid certification path"), so on a failure the captured chain is re-run through CertPathValidator with the same anchors and date: only NO_TRUST_ANCHOR or a blocked.certs "Untrusted certificate" is REJECTED; algorithm, expiry and constraint failures are ERR. Live is weaker, and only offline decides this row: Probe live prints JSSE's whole cause chain, and CERT_RE accepts PKIX path, CertPathValidatorException and CertificateException. JSSE reports an algorithm-constraint failure as PKIX path validation failed: ... Algorithm constraints check failed, and every builder failure, a distrust refusal included, as PKIX path building failed: ... unable to
6E7E
find valid certification path. So a live java CERT-FAIL means PKIX refused the chain, not that it refused it for trust. NEGCONTROL runs curl, so it doesn't catch this. The live probe can't tell the cases apart without the chain, which it doesn't capture. |
| go | path | x509.Certificate.Verify with the system pool, which is exactly what crypto/tls uses. That includes Go's distrusted-SPKI list. The probe decides on the error type: only UnknownAuthorityError, or NoValidChains with an empty detail (go1.27.3 returns that only when the distrusted-SPKI list dropped every chain), counts as REJECTED. EKU, policy, name-constraint and expiry failures are ERR. |
| python-urllib | store | Python has no chain-verify API, and a handshake needs a private key we don't have. This row feeds ssl.get_default_verify_paths() to openssl verify -x509_strict -partial_chain, the 3.13+ default-context flags. Python links that same libssl. |
| pip | store | Same, over the union of the system paths (truststore) and pip's vendored certifi. pip 24.2+ trusts both. |
| httpie | store | Same, over the union of the system paths and requests' bundle. httpie 3.2.x calls load_default_certs() (ensure_default_certs_loaded) before requests adds certifi. A certifi-only model read FIXED at the pre-fix pin while live connected to all 12 hosts. |
| node, node-sysca | anchor | Checks whether tls.getCACertificates('default') (bundled, plus system under --use-system-ca) contains a cert that anchors the chain, with links checked by X509Certificate.checkIssued and verify. There is no time check, and any store member counts as an anchor, so this can only err toward VULNERABLE. |
| bun | anchor + store | The same walk over bun's compiled-in list, unioned with openssl verify over the system paths. At the pre-fix pin bun connected to the AAA hosts, and AAA is in no compiled-in list. |
| deno | anchor | The same walk. Deno's node:tls list matched live at both pins. |
| curl, wget, git | N/A (live-only) | These clients verify only inside a handshake. The store they read is the one the openssl row checks. |
| deno-sysstore | N/A (live-only) | node:tls does not reflect DENO_TLS_CA_STORE. |
| uv, uv-system-certs | N/A (live-only) | rustls verifies only inside a handshake, and the webpki roots are compiled in (pkgmgr truststores inventories them). |
At the pre-fix pin, the offline cells matched the live cells for every row that has both, in both target groups. That is the bar for adding an offline probe: if offline disagrees with live at the baseline, the probe is wrong, and the row is N/A until it is fixed.
go-blocklist, node-blocklist, java-blocklist and uv-blocklist run with the
anchors trusted: SSL_CERT_FILE set to bundle+anchors for Go and uv,
NODE_EXTRA_CA_CERTS set to the anchors for node, and explicit trust anchors for Java.
With the anchors trusted, a FIXED can only come from the client's own blocklist. Those
blocklists are Go's distrusted SPKIs, node's kBlockedSPKIs, the JDK's blocked.certs
and rustls-webpki's list. A row runs only if expected.txt names it in blocklist_rows.
A client with no blocklist for a given response would otherwise read VULNERABLE forever.
node's and uv's blocklists fire only inside a handshake, so those rows are live-only.
Java's has no live row, because that would need a keystore built with keytool, and
offline already runs the same PKIX code.
- Add a response directory. Copy
responses/cve-2026-111943/toresponses/<id>/and fill it in:targets.txtlists the hosts that must be refused, one per line, as<host> <group> <label>. Each group becomes a table column.positives.txtlists two or more hosts that must connect, on unrelated long-lived roots.chains/bad/<host>.pemis each target's served chain, leaf first. Take it from the disclosure, or fromopenssl s_client -showcerts. Public certs only, never keys.chains/good/<host>.pemis each positive's chain.chains/anchors.pemholds the distrusted roots. Check their fingerprints againstpackages/ca-certificates/distrust.txt.- Run
shasum -a 256over the PEMs to writechains/SHA256SUMS, and cite the source inchains/README.md. - In
expected.txt, setverify_atto an instant inside every chain's validity,blocklist_rowsto the rows that apply, and the expected failure class.
- Baseline at a pre-fix pin. It must read VULNERABLE. Set
locked_committo a pkgs commit from before any fix, then run all six tasks. Expect VULNERABLE rows (exit 2), SELFTEST FIXED and NEGCONTROL VULNERABLE. A row that reads FIXED at the baseline is not being tested by this response. Find out why before trusting it later. Save the output. - Per PR. Point
[upstream]at the PR head and run the tasks it affects:[upstream] repo = "https://github.com/gominimal/pkgs" # a fork PR: the fork's URL branch = "<pr-branch>" locked_commit = "<pr head sha>"
min task run live -C "$X" --no-input --only '^uv'on pkgs#967's head (uv-0.13.4-webpki-lock) gave uv 0.13.4, FIXED on all three uv rows. Packages the PR's CI hasn't built yet get built on first use, so a toolchain bump means a long first run. As an alternative,min session activatea worktree of the PR and runbash run.sh ...in it withmin session exec. Use a fresh session name each time, because activate snapshots the tree. - After merge. Run
min updatein the scratch copy to re-pin to main, then run all six tasks. Every row must read FIXED, or N/A with the reason above. Exit must be 0. - Weekly regression. Run
min update, then all six tasks, for every directory underresponses/. Any non-zero exit needs a human. Exit 3 most often means a chain has gone stale (NEGCONTROL BROKEN), and the fix is to re-capture it.
Phase 1 (now): a manual playbook. Nothing runs this on a schedule yet, and it is not
a CI check. Whoever drives a response runs it, following the playbook above: baseline,
per PR, after merge. Run it too on any PR that changes a trust store or a TLS client
(ca-certificates, a runtime that bundles its own roots, a Rust crate lock that pins
webpki). That person owns the result and posts the table on the PR.
Phase 2: a weekly scheduled run. min update, then all six tasks for every directory
under responses/, retrying once on exit 125. A non-zero exit posts to #pkgmgr-mgr
(Slack channel C0B1J3S34SV). That is the channel the pkgmgr runner's failure backstop
posts to, so the same pkgsec on-call that watches the nightly runs owns it. Tracked with
the weekly pkgmgr truststores scan (gominimal/pkgmgr-rs#1021). Until it exists, there
is no alert, only the manual runs.
- Six tasks. The package lists are split three ways because some packages install
the same paths (see "Why six tasks"). When you add a client, add its package to every
task whose rows should see it, and keep
live-*andoffline-*identical apart from--mode. Adding a jdk or node flavour means a new task pair, not another entry in an existing one. - The pin.
[upstream] locked_commitis what gets tested, never the checkout this file sits in. Today it pins the pkgs main commit the last full run was verified at. To test current main, runmin update(in a scratch copy, see "Running it") and commit the new pin with the run's results. To test a PR, point[upstream]at its head (step 3 of the playbook). Don't commit a PR-head pin. - Chain expiry. Offline verifies at
verify_at, so captured chains never go stale there. Live mode handshakes with the real targets, though, and their leaves expire: for cve-2026-111943, all 12 on 2027-05-03. On that day NEGCONTROL reads BROKEN and every live run exits 3. run.sh prints the earliest target leaf expiry in its header, warns from 60 days before (EXPIRY_WARN_DAYSoverrides), and puts it in the JSON asearliest_target_leaf_expiry. Before it passes, re-capturechains/bad/from the targets (if the disclosure re-issued them) and moveverify_at. Otherwise retire the response's live tasks and keep offline. - The targets are someone else's hosts.
*.we-have-a-problem.comare the disclosure's demonstration sites. If they go away, NEGCONTROL reads BROKEN in live mode, and offline keeps working fromchains/. The positives (letsencrypt.org,www.google.com) rotate their own certs, so live mode needs no upkeep for them. - Exit 125. run.sh never exits 125. If
min task runreturns 125 with the output cut off mid-run, min dropped the task's output stream, and the fix is to rerun. During development this hit 3 of about 20 streamedliveruns. When run.sh ran 6 times back to back inside a single task, with its output going to files, there were 0 failures in 6. The scheduled run retries once on 125.
- Only the hosts in
targets.txtandpositives.txtare contacted, and only inlivemode.offlineopens no sockets. Host names are validated before use. - There are no secrets and no private keys.
chains/holds public CA and leaf certificates only, pinned bySHA256SUMS, with the source cited inchains/README.md. - Nothing is piped from the network into a shell. The Go and Java probes are compiled
from this directory inside the box, with
GOPROXY=offand no module downloads. - All scratch files live under one
mktemp -d, which is removed on exit.HOMEand the XDG dirs point inside it, so clients write no config or cache anywhere else.