8000 tests/tls-trust: TLS trust harness for CA distrust responses by bryan-minimal · Pull Request #976 · gominimal/pkgs · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,25 @@ stacks should use `stack` and live at `stacks/<name>/stack.ncl`.

Current stacks cover: aeneas, bun, cabal, cmake, deno, go, gradle, make, maven, meson, npm, ocaml, odin, pip, pnpm, pulumi-go, pulumi-nodejs, rust, shell, stack, uv, zig.

### Tests

`tests/<name>/` holds checks that exercise packages the way users get them, across
several packages at once. A single package's checks stay in that package's `tests`
block. Each directory under `tests/`:

* is a self-contained min project: its own `minimal.toml`, with `[upstream]` pinning
`gominimal/pkgs` (`locked_commit`) and `[tasks]` to run. It is not a package or a
stack, nothing imports it, and the package checks never evaluate it;
* tests the commit its pin names, never the checkout it sits in. `min update` re-pins
it to main, or `[upstream]` can point at a PR head;
* has a README saying how to run it, what each result means, and who acts on a
failure;
* holds no secrets or private keys. Captured data (certificates, fixtures) is public,
pinned by checksum, and cites its source;
* has no `profiles/` subdirectory, because min reserves `profiles/<name>/profile.ncl`.

Current tests: `tls-trust` (every TLS client refuses a distrusted CA, per distrust response).



## Package declarations
Expand Down
243 changes: 243 additions & 0 deletions tests/tls-trust/README.md

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions tests/tls-trust/lib/Probe.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// lib/Probe.java: the JDK probe. Compiled once per run with javac.
// java Probe live URL TIMEOUT_S
// java Probe offline CHAIN.pem EPOCH_S [ROOTS.pem]
//
// offline runs the JDK's own PKIX CertPathBuilder against the default JSSE
// trust store (javax.net.ssl.trustStore, else jssecacerts, else cacerts), at a
// pinned instant, revocation off. The builder applies lib/security/blocked.certs
// (UntrustedCertificates) and jdk.certpath.disabledAlgorithms, the same checks
// the TLS trust manager runs. It does not apply jdk.security.caDistrustPolicies,
// which are TLS-only date cut-offs (none is used for this response).
// With ROOTS.pem the trust anchors are exactly that file, which isolates
// blocked.certs (the java-blocklist row).
//
// REJECTED is a TRUST refusal, never "the builder found no path". The builder
// reduces every failed candidate path to "unable to find valid certification
// path to requested target", whatever the reason, so on a builder failure the
// captured chain is re-run through the PKIX CertPathValidator with the same
// anchors and date, and only NO_TRUST_ANCHOR or blocked.certs' "Untrusted
// certificate" mark REJECTED. Algorithm constraints, expiry and basic
// constraints are ERR, as in the Go probe and lib/common.sh.
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.URI;
import java.security.KeyStore;
import java.security.cert.*;
import java.util.*;
import javax.net.ssl.HttpsURLConnection;

public class Probe {
public static void main(String[] a) throws Exception {
if (a[0].equals("live")) live(a[1], Double.parseDouble(a[2]));
else offline(a[1], Long.parseLong(a[2]), a.length > 3 ? a[3] : null);
}

static void live(String url, double t) {
try {
HttpsURLConnection c = (HttpsURLConnection) new URI(url).toURL().openConnection();
c.setConnectTimeout((int) (t * 1000)); c.setReadTimeout((int) (t * 1000)); 9798
System.out.println("@@CONNECTED@@ HTTP " + c.getResponseCode());
} catch (Throwable e) { System.out.println("FAIL " + causes(e)); }
}

static List<X509Certificate> load(String file) throws Exception {
List<X509Certificate> out = new ArrayList<>();
try (InputStream in = new FileInputStream(file)) {
for (Certificate c : CertificateFactory.getInstance("X.509").generateCertificates(in)) out.add((X509Certificate) c);
}
return out;
}

static void offline(String chainFile, long epoch, String rootsFile) throws Exception {
List<X509Certificate> chain = load(chainFile);
X509CertSelector target = new X509CertSelector();
target.setCertificate(chain.get(0));
PKIXBuilderParameters p;
if (rootsFile != null) {
Set<TrustAnchor> anchors = new HashSet<>();
for (X509Certificate r : load(rootsFile)) anchors.add(new TrustAnchor(r, null));
System.out.println("trust anchors: " + rootsFile + " (" + anchors.size() + ")");
p = new PKIXBuilderParameters(anchors, target);
} else {
p = new PKIXBuilderParameters(defaultTrustStore(true), target);
}
p.setRevocationEnabled(false);
p.setDate(new Date(epoch * 1000));
p.addCertStore(CertStore.getInstance("Collection", new CollectionCertStoreParameters(chain)));
try {
PKIXCertPathBuilderResult r = (PKIXCertPathBuilderResult) CertPathBuilder.getInstance("PKIX").build(p);
System.out.println("@@ACCEPTED@@ anchored at " + r.getTrustAnchor().getTrustedCert().getSubjectX500Principal());
} catch (CertPathBuilderException e) {
System.out.println(causes(e));
String verdict = validatorVerdict(chain, rootsFile, epoch);
System.out.println("validator: " + verdict);
if (verdict.startsWith("TRUST")) System.out.println("@@REJECTED@@");
}
}

// Why the chain fails, from the validator: "TRUST ..." for a trust refusal
// (no trust anchor, or a blocked.certs entry), "OTHER ..." for anything else.
static String validatorVerdict(List<X509Certificate> chain, String rootsFile, long epoch) {
try {
List<X509Certificate> path = new ArrayList<>(chain);
X509Certificate last = path.get(path.size() - 1);
// A CertPath excludes the trust anchor: drop a trailing self-signed root.
if (path.size() > 1 && last.getSubjectX500Principal().equals(last.getIssuerX500Principal())) {
path.remove(path.size() - 1);
}
PKIXParameters p;
if (rootsFile != null) {
Set<TrustAnchor> anchors = new HashSet<>();
for (X509Certificate r : load(rootsFile)) anchors.add(new TrustAnchor(r, null));
p = new PKIXParameters(anchors);
} else {
p = new PKIXParameters(defaultTrustStore(false));
}
p.setRevocationEnabled(false);
p.setDate(new Date(epoch * 1000));
CertPath cp = CertificateFactory.getInstance("X.509").generateCertPath(path);
CertPathValidator.getInstance("PKIX").validate(cp, p);
return "OTHER the builder failed but the captured chain validates";
} catch (CertPathValidatorException v) {
CertPathValidatorException.Reason r = v.getReason();
String msg = String.valueOf(v.getMessage());
if (r == PKIXReason.NO_TRUST_ANCHOR || msg.contains("Untrusted certificate")) {
return "TRUST " + r + ": " + msg;
}
return "OTHER " + r + ": " + msg;
} catch (Exception x) {
return "OTHER " + causes(x);
}
}

// The JSSE default: javax.net.ssl.trustStore, else jssecacerts, else cacerts.
static KeyStore defaultTrustStore(boolean announce) throws Exception {
String sep = File.separator, home = System.getProperty("java.home");
String path = System.getProperty("javax.net.ssl.trustStore");
if (path == null) {
File jsse = new File(home + sep + "lib" + sep + "security" + sep + "jssecacerts");
path = jsse.exists() ? jsse.getPath() : home + sep + "lib" + sep + "security" + sep + "cacerts";
}
KeyStore ks = KeyStore.getInstance(new File(path), (char[]) null);
if (announce) System.out.println("trust store: " + path + " (" + ks.size() + " entries)");
return ks;
}

static String causes(Throwable e) {
StringBuilder sb = new StringBuilder();
for (Throwable x = e; x != null; x = x.getCause()) sb.append(x.getClass().getName()).append(": ").append(x.getMessage()).append(" | ");
return sb.toString();
}
}
64 changes: 64 additions & 0 deletions tests/tls-trust/lib/c-clients.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# lib/c-clients.sh: curl, wget, openssl, gnutls-cli, git.
# live_* HOST -> handshake + HTTP request (see lib/common.sh for the contract)
# off_* CHAIN -> path validation against the client's default store, no network

10BC8 live_curl() {
local o rc; o=$(to curl -q -sS -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "https://$1/" 2>&1); rc=$?
echo "rc=$rc $o"; [ $rc -eq 0 ] && echo "@@CONNECTED@@"
[ $rc -eq 60 ] && echo "SSL certificate problem (curl exit 60)"; return 0
}

live_wget() {
local o rc; o=$(to wget -O /dev/null --timeout="$TIMEOUT" --tries=1 "https://$1/" 2>&1); rc=$?
echo "rc=$rc"; echo "$o" | tail -4
# 8 = server answered with an HTTP error, which still means TLS succeeded.
{ [ $rc -eq 0 ] || [ $rc -eq 8 ]; } && echo "@@CONNECTED@@"
[ $rc -eq 5 ] && echo "SSL certificate problem (wget exit 5 = SSL verification failure)"; return 0
}

live_openssl() {
local o rc; o=$(to openssl s_client -connect "$1:443" -servername "$1" -verify_return_error -brief </dev/null 2>&1); rc=$?
echo "rc=$rc"; echo "$o" | grep -Ei 'verif|error|CONNECTION ESTABLISHED|Protocol' | head -6
if [ $rc -eq 0 ] && echo "$o" | grep -q 'CONNECTION ESTABLISHED' && ! echo "$o" | grep -qi 'verify error'; then echo "@@CONNECTED@@"; fi
return 0
}
# OpenSSL's own default store (OPENSSLDIR/cert.pem + certs/, or SSL_CERT_FILE /
# SSL_CERT_DIR when set), exactly what libssl clients load with
# SSL_CTX_set_default_verify_paths. Inside the box OPENSSLDIR is our package's.
off_openssl() {
local o; o=$(to openssl verify -purpose sslserver -attime "$VERIFY_AT_EPOCH" -untrusted "$1" "$1" 2>&1)
ossl_verify_verdict "$o"
}

live_gnutls() {
local o rc; o=$(to gnutls-cli --port 443 "$1" </dev/null 2>&1); rc=$?
echo "rc=$rc"; echo "$o" | grep -Ei 'verif|trusted|Status|Handshake|error|fail' | head -6
echo "$o" | grep -q 'Handshake was completed' && echo "@@CONNECTED@@"; return 0
}
# certtool --verify with no --load-ca-certificate uses gnutls's system trust
# (the store gnutls-cli uses), then runs gnutls's real chain verifier.
off_gnutls() {
local o; o=$(to certtool --verify --verify-purpose=1.3.6.1.5.5.7.3.1 --attime="$VERIFY_AT_ISO" --infile "$1" 2>&1)
echo "$o" | grep -Ei 'verification output|trusted|issuer|expired|error' | tail -4
if echo "$o" | grep -q 'Chain verification output: Verified'; then echo '@@ACCEPTED@@'
elif echo "$o" | grep -Eq 'Chain verification output: Not verified' \
&& echo "$o" | grep -Eq 'issuer is unknown|NOT trusted|distrusted' \
&& ! echo "$o" | grep -Eqi 'expired|not activated|signature failure'; then echo '@@REJECTED@@'
fi
}

live_git() {
local o rc; o=$(GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/false GIT_CONFIG_NOSYSTEM=1 to git -c http.lowSpeedTime="$TIMEOUT" -c http.lowSpeedLimit=1 ls-remote "https://$1/x.git" 2>&1); rc=$?
echo "rc=$rc"; echo "$o" | tail -3
# TLS errors win; otherwise any HTTP-level answer means the handshake passed.
if ! echo "$o" | grep -Eqi -- "$CERT_RE"; then
if [ $rc -eq 0 ] || echo "$o" | grep -Eqi 'returned error: [0-9]|not found|not valid: is this a git repository|could not read Username|Authentication failed|dumb http'; then echo "@@CONNECTED@@"; fi
fi; return 0
}

NA_HANDSHAKE='libcurl/libssl verify only inside a handshake; the store they read is the openssl row'
row curl curl "curl --version | head -1 | cut -d' ' -f1-2,5" live_curl - "N/A (live-only): $NA_HANDSHAKE"
row wget wget "wget --version | head -1 | cut -d' ' -f1-3" live_wget - "N/A (live-only): wget verifies only inside a handshake"
row openssl openssl "openssl version | cut -d' ' -f1-2" live_openssl off_openssl "path: openssl verify, default store"
row gnutls-cli gnutls-cli "gnutls-cli --version | head -1" live_gnutls off_gnutls "path: certtool --verify, gnutls system trust"
row git git "git --version" live_git - "N/A (live-only): $NA_HANDSHAKE"
112 changes: 112 additions & 0 deletions tests/tls-trust/lib/common.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# lib/common.sh: shared helpers for the TLS trust harness. Sourced by run.sh.
#
# Probe contracts (every probe prints free-form output to stdout):
# live probes print @@CONNECTED@@ only when the server's HTTP layer
# answered, i.e. the handshake completed and the chain was trusted.
# offline probes print @@ACCEPTED@@ when the runtime's verifier built a
# trusted path, or @@REJECTED@@ when it refused the chain for a
# TRUST reason (unknown issuer, untrusted, distrusted, blocked).
# Anything else (expiry, parse error, crash) classifies as ERR.

# to CMD...: run CMD under the per-probe deadline. Probes pass $TIMEOUT to the
# client's own timeout knob too; this is the backstop for clients that hang.
to() { timeout -k 3 "$((TIMEOUT + 5))" "$@"; }

have() { command -v "$1" >/dev/null 2>&1; }

# Only certificate-verification errors count as a correct rejection, and of
# those only TRUST failures: NOT_TRUST_RE (expiry, name mismatch) wins over
# CERT_RE and classifies as ERR, so e.g. Go's "x509: certificate has expired"
# or a hostname mismatch can never read as FIXED. rustls (uv, deno) says
# "certificate expired" / "certificate not valid yet".
NOT_TRUST_RE='certificate has expired|certificate is expired|certificate expired|certificate not valid yet|CERT_HAS_EXPIRED|not yet valid|CERT_NOT_YET_VALID|x509: certificate is valid for|ERR_TLS_CERT_ALTNAME_INVALID|Hostname mismatch|hostname mismatch|does not match target host|NotValidForName|no alternative certificate subject name matches|certificate subject name .* does not match|The certificate has expired|validity check failed'
# Go: only its two trust failures count. "signed by unknown authority" is
# x509.UnknownAuthorityError. "no valid chains built" with NOTHING after it is
# CertificateInvalidError{NoValidChains, Detail: ""}, which go1.27.3 returns
# only when every built chain was dropped by the distrusted-SPKI list
# (crypto/x509 verify.go: policy and EKU drops add a Detail, constraint drops
# return a different error). So "x509: " alone, or "tls: failed to verify
# certificate:" (Go's prefix on every x509 error, EKU and policy included),
# is never a refusal.
CERT_RE='certificate verify failed|CERTIFICATE_VERIFY_FAILED|SSL certificate problem|unable to get local issuer|unable to get issuer|UNABLE_TO_GET_ISSUER_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE|SELF_SIGNED_CERT_IN_CHAIN|CERT_UNTRUSTED|CERT_REVOKED|certificate is NOT trusted|PKI verification of server certificate failed|issuer is not known|x509: certificate signed by unknown authority|x509: no valid chains built *($|[^: ])|PKIX path|CertPathValidatorException|CertificateException|UnknownIssuer|invalid peer certificate|unable to verify the first certificate|verify error:|Verify return code: [1-9]|server certificate verification failed|ERR_TLS_CERT|untrusted|distrusted|revoked|cannot verify .*certificate|SSLCertVerificationError'
# rc=124/137: the backstop deadline in to() killed the probe.
NET_RE='^rc=(124|137)( |$)|Could not resolve|resolve host|Name or service not known|Temporary failure in name resolution|getaddrinfo|ENOTFOUND|EAI_AGAIN|dns error|No address associated|timed out|[Tt]imeout|TimeoutError|ETIMEDOUT|Connection refused|ECONNREFUSED|Network is unreachable|No route to host|ECONNRESET|connection reset|UnknownHostException|no such host|i/o timeout|CONNECT_TIMEOUT|UND FF62 _ERR_SOCKET'

# classify MODE RAWFILE -> one cell value.
# live: CONNECTED | CERT-FAIL | NET-ERR | ERR (incl. expiry / name mismatch)
# offline: ACCEPTED | REJECTED | ERR
classify() {
if [ "$1" = offline ]; then
if grep -q '@@ACCEPTED@@' "$2"; then echo ACCEPTED
elif grep -q '@@REJECTED@@' "$2"; then echo REJECTED
else echo ERR; fi
return
fi
if grep -q '@@CONNECTED@@' "$2"; then echo CONNECTED
elif grep -Eqi -- "$NOT_TRUST_RE" "$2"; then echo ERR
elif grep -Eqi -- "$CERT_RE" "$2"; then echo CERT-FAIL
elif grep -Eq -- "$NET_RE" "$2"; then echo NET-ERR
else echo ERR; fi
}

# ossl_verify_verdict OUTPUT: turn `openssl verify` output into the offline
# contract. Only the X509_V_ERR codes that mean "no trusted path" count as a
# rejection: 2 UNABLE_TO_GET_ISSUER_CERT, 18 DEPTH_ZERO_SELF_SIGNED,
# 19 SELF_SIGNED_CERT_IN_CHAIN, 20 UNABLE_TO_GET_ISSUER_CERT_LOCALLY,
# 27 CERT_UNTRUSTED, 28 CERT_REJECTED. Expiry (9/10) and the rest are ERR.
ossl_verify_verdict() {
printf '%s\n' "$1" | grep -v '^$' | tail -3
if printf '%s\n' "$1" | grep -q ': OK$'; then echo '@@ACCEPTED@@'
elif printf '%s\n' "$1" | grep -Eq '^error (2|18|19|20|27|28) at '; then echo '@@REJECTED@@'
fi
}

# ossl_verify_with CAFILE CAPATH CHAIN [EXTRA FLAGS...]: `openssl verify` the
# chain's leaf against exactly the given store ("-" = none for that half), at
# the profile's pinned instant. Never falls back to the default paths for a
# half that was not given, which is what a bare -CAfile would do.
ossl_verify_with() {
local cafile=$1 capath=$2 chain=$3; shift 3
local a=()
if [ "$cafile" = - ]; then a+=(-no-CAfile); else a+=(-CAfile "$cafile"); fi
if [ "$capath" = - ]; then a+=(-no-CApath); else a+=(-CApath "$capath"); fi
to openssl verify -purpose sslserver -attime "$VERIFY_AT_EPOCH" -no-CAstore "${a[@]}" "$@" \
-untrusted "$chain" "$chain" 2>&1
}

# json_str STRING: a JSON string literal.
json_str() {
local s=$1
s=${s//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\t'/\\t}; s=${s//$'\r'/}; s=${s//$'\n'/\\n}
printf '"%s"' "$s"
}

# ossl_union CHAIN CAFILE1 CAPATH1 [CAFILE2 CAPATH2 ...]: for clients that
# trust the UNION of several stores. Accepted if any store accepts, rejected
# only if every store rejects for a trust reason. Python-side verification
# flags (3.13+ create_default_context: -x509_strict -partial_chain) apply.
ossl_union() {
local chain=$1 o v any_ok=0 all_rej=1; shift
while [ $# -ge 2 ]; do
o=$(ossl_verify_with "$1" "$2" "$chain" -x509_strict -partial_chain)
v=$(ossl_verify_verdict "$o")
echo "store cafile=$1 capath=$2:"; printf '%s\n' "$v" | sed 's/@@//g; s/^/ /'
case $v in *@@ACCEPTED@@*) any_ok=1 ;; esac
case $v in *@@REJECTED@@*) ;; *) all_rej=0 ;; esac
shift 2
done
if [ $any_ok = 1 ]; then echo '@@ACCEPTED@@'; elif [ $all_rej = 1 ]; then echo '@@REJECTED@@'; fi
}

# marker_union OUTPUT...: combine sub-probe outputs for a client that trusts
# the union of several stores (ACCEPTED if any accepted, REJECTED only if all
# rejected). Sub-markers are defanged so only the combined marker counts.
marker_union() {
local o any_ok=0 all_rej=1
for o in "$@"; do
printf '%s\n' "$o" | sed 's/@@//g; s/^/ /'
case $o in *@@ACCEPTED@@*) any_ok=1 ;; esac
case $o in *@@REJECTED@@*) ;; *) all_rej=0 ;; esac
done
if [ $any_ok = 1 ]; then echo '@@ACCEPTED@@'; elif [ $all_rej = 1 ]; then echo '@@REJECTED@@'; fi
}
Loading
Loading
0