A TLS deployment check can look successful while proving the wrong thing. openssl s_client may establish a TLS session, print a certificate, report verification diagnostics, and still return a process status that a wrapper treats as success. The acceptance question is narrower and harder: when the server presents a certificate that is not valid for the intended DNS identity api.lab.test, does the probe itself reject that endpoint with a process verdict suitable for a release gate?
For OpenSSL 3.5, the answer is: not unless the probe is configured to verify that hostname and to return verification errors instead of continuing diagnostically. The OpenSSL 3.5 openssl-s_client documentation explicitly describes s_client as a diagnostic tool; it says verification normally continues after errors unless -verify_return_error is used, while -servername sets SNI rather than the hostname-verification predicate. It also states that -showcerts displays what the server sent, not a verified chain.
This playbook qualifies that negative path with owned loopback fixtures only. The commissioning experiment for batch 2026-09-25-batch2 used synthetic DNS names, private throwaway CAs, 127.0.0.1:8443, no public certificates, no credentials, and no system trust-store modification. The documentation baseline is intentionally fixed at OpenSSL 3.5 and was accessed September 25, 2026; it is not a claim that those pages identify some different “latest” release.
The laboratory described below was also reproduced for this article. Where exact exit statuses, fingerprints or diagnostics are stated as observed, they belong only to the recorded build and fixtures. The acceptance method is portable; those literal values are not universal constants.
Define the identity claim the probe must enforce
The contract is simple enough to write before touching OpenSSL:
Ledger field | Declared value |
Intended URL identity | api.lab.test |
Connection address | 127.0.0.1:8443 |
TLS SNI name | api.lab.test |
Verification hostname | api.lab.test |
Accepted trust anchor | lab root.pem only |
Positive certificate | SAN DNS:api.lab.test, signed by root.pem |
Wrong-name control | SAN DNS:other.lab.test, signed by the same trusted root |
Untrusted control | SAN DNS:api.lab.test, signed by different root2.pem |
Transport control | nothing listening on 127.0.0.1:8443 |
The release claim is therefore not “TLS worked.” It is:
The endpoint reached at the declared routing address presented a server certificate that chains to the declared trust root and is valid for the declared DNS identity api.lab.test, and the qualified probe returned success only for that condition.
That distinction matters because several TLS properties are independent. TCP routing decides where packets go. SNI can influence which virtual-host certificate a server selects. Certificate path validation determines whether a chain reaches a trusted anchor. Hostname verification tests whether the authenticated certificate represents the intended DNS name. Application authorization happens later and is outside this laboratory.
OpenSSL documents these as distinct controls. -servername name places the supplied name in the ClientHello SNI extension; -verify_hostname hostname performs the certificate-name match. The verification-options documentation says that the latter checks the supplied hostname against DNS identifiers in the certificate.
Connecting to 127.0.0.1 therefore does not silently change this contract into an IP-identity check. The DNS identity remains api.lab.test. If the policy intended IP identity instead, that would be a different contract and OpenSSL provides -verify_ip for it.
This article complements Refonte Learning’s broader secure API foundations rather than repeating an API-security checklist. The concern here is evidence quality: can the particular diagnostic client used in a deployment workflow distinguish the valid server identity from controlled invalid identities?
The acceptance policy is deliberately asymmetric:
Accept only when the qualified probe passes the trusted, correct-name fixture and rejects every negative control for the expected class of reason.
Repair when the probe is missing a required verification field, the trust input is wrong, or the serving certificate is wrong.
Hold when evidence is ambiguous, for example, a harness timeout, stale listener, missing status file, unrecorded executable, or a diagnostic/implementation discrepancy.
Block an endpoint that is actually wrong under the declared identity policy, such as a trusted certificate whose SAN does not contain api.lab.test or a correct-name certificate whose issuer is not trusted.
A printed PEM block or a completed handshake does not meet this contract. OpenSSL states that -showcerts only displays the certificate list sent by the server and that the list is not a verified chain.
Pin the client, trust inputs and loopback environment
Release evidence is weak if it records only “OpenSSL passed.” Before generating a certificate, capture the executable, build, operating system, shell, trust file, identities and comparison-client backend.
OpenSSL 3.5’s openssl-version documentation says openssl version -a prints all available version information, including version, build date, build options, compilation flags, platform and installation directories; the documentation specifically notes that this full output is typically useful in bug reports.
A reproducible preflight can begin as follows:
#!/usr/bin/env bash
set -euo pipefail
BATCH='2026-09-25-batch2'
LAB="$PWD/tls-probe-${BATCH}"
# Refuse path collisions. Never merge this lab into an existing directory.
if [ -e "$LAB" ]; then
printf 'Refusing existing path: %s\n' "$LAB" >&2
exit 2
fi
umask 077
mkdir "$LAB"
cd "$LAB"
# Marker used later to constrain cleanup to this lab.
printf '%s\n' "$BATCH" > .lab-owner-marker
mkdir evidence
{
printf 'commissioning_batch=%s\n' "$BATCH"
printf 'openssl_path=%s\n' "$(command -v openssl)"
openssl version -a
printf 'curl_path=%s\n' "$(command -v curl)"
curl --version
printf 'shell_env=%s\n' "${SHELL:-unknown}"
printf 'bash_version=%s\n' "${BASH_VERSION:-unknown}"
printf 'os_release_begin\n'
if [ -r /etc/os-release ]; then
cat /etc/os-release
fi
printf 'os_release_end\n'
uname -a
printf 'routing_address=%s\n' '127.0.0.1:8443'
printf 'sni_name=%s\n' 'api.lab.test'
printf 'verification_hostname=%s\n' 'api.lab.test'
} > evidence/environment.txtThe commissioning reproduction used the following observed environment, which is useful because its OpenSSL binary actually belongs to the fixed 3.5 documentation line:
Recorded item | Observed value |
OpenSSL executable | /usr/bin/openssl |
OpenSSL | OpenSSL 3.5.5 27 Jan 2026 |
Library | OpenSSL 3.5.5 27 Jan 2026 |
Build time | Fri Apr 3 10:05:32 2026 UTC |
Platform | debian-amd64 |
OpenSSL directory | /usr/lib/ssl |
OS | Debian GNU/Linux 13.3, trixie |
Kernel | Linux 6.18.44, x86_64 |
Shell | /bin/bash, Bash 5.2.37(1)-release |
curl executable | /usr/local/bin/curl |
curl | 8.10.1 |
curl TLS backend | OpenSSL/3.5.5 |
Connection | 127.0.0.1:8443 |
SNI | api.lab.test |
Verification hostname | api.lab.test |
Fixture commit | e5ea1564a8ea413db0089c85f017d0f25d253917 |
The exact compiler flags were also retained from openssl version -a; they included the Debian build’s hardening and optimization settings. Do not abbreviate that output in the archived evidence even if an article or ticket shows only the summary.
The fixture definitions and harness should be committed before the run, while generated keys, certificates and logs remain outside source control:
.key
.csr
*.pem
*.srl
evidence/
server.*git init
git config user.email '[email protected]'
git config user.name 'TLS Probe Lab'
git add \
.gitignore \
root.cnf root2.cnf api.cnf other.cnf \
api.ext other.ext response.txt \
run_sclient.py env_capture.sh
git commit -m 'TLS probe fixture 2026-09-25-batch2'
git rev-parse HEAD >> evidence/fixture-commit.txtOnce root.pem exists, record its path and digest rather than assuming a file named root.pem is the same trust input between runs:
{
printf 'trust_file=%s\n' "$PWD/root.pem"
printf 'trust_file_sha256='
sha256sum root.pem | awk '{print $1}'
} >> evidence/environment.txtThe reproduction’s observed SHA-256 digest of the trusted root file was:
d55ff3c5c0ed3e2ce50169fba6d2798d81f8b6cde3513d9be877eda3ac7d8b80That value is not an expected constant for readers: newly generated keys necessarily produce different certificates and fingerprints.
These environmental fields are part of the probe’s specification, not operational trivia. A wrapper can behave differently after an OpenSSL package update, a curl backend change, a trust-file replacement or a shell-pipeline modification. This narrow concern is compatible with broader API engineering practices, but it does not turn the laboratory into an API-tooling survey.
Create valid and deliberately invalid certificate fixtures
The laboratory needs two private roots and three server leaves:
root.pem: trusted throwaway CA.
api.pem: trusted leaf, SAN api.lab.test.
other.pem: trusted leaf, SAN other.lab.test.
root2.pem: second throwaway CA that the client will not trust.
api-untrusted.pem: SAN api.lab.test, signed by root2.pem.
The root configuration is explicit:
# root.cnf
[ req ]
prompt = no
distinguished_name = dn
x509_extensions = v3_ca
[ dn ]
O = Refonte Learning TLS Probe Lab
CN = Refonte TLS Probe Lab Root
[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always, issuerThe second root differs only in identity:
# root2.cnf
[ req ]
prompt = no
distinguished_name = dn
x509_extensions = v3_ca
[ dn ]
O = Refonte Learning TLS Probe Lab
CN = Refonte TLS Probe Untrusted Root
[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always, issuerUse separate request files for the two leaf subjects:
# api.cnf
[ req ]
prompt = no
distinguished_name = dn
[ dn ]
O = Refonte Learning TLS Probe Lab
CN = api.lab.test# other.cnf
[ req ]
prompt = no
distinguished_name = dn
[ dn ]
O = Refonte Learning TLS Probe Lab
CN = other.lab.testMost importantly, put the server extensions in the signing configuration, not merely in the CSR and not behind an assumption that openssl x509 will copy them. OpenSSL 3.5’s openssl-x509 documentation states that extensions in a CSR are not copied by default and documents -extfile plus -extensions for extensions that should be added to the issued certificate.
For the correct server identity:
# api.ext
[ server_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
[ alt_names ]
DNS.1 = api.lab.testFor the wrong-name control:
# other.ext
[ server_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
[ alt_names ]
DNS.1 = other.lab.testThe OpenSSL 3.5 x509v3_config documentation defines CA:FALSE for an end-entity basic-constraints extension, lists digitalSignature and keyEncipherment among key-usage values, identifies serverAuth as the TLS server-authentication extended key usage, and documents the DNS: form of subjectAltName.
Generate everything under the restrictive umask established earlier:
# Trusted root: 30-day throwaway lab validity.
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out root.key
openssl req \
-x509 \
-new \
-key root.key \
-sha256 \
-days 30 \
-config root.cnf \
-out root.pem
# Deliberately untrusted root.
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out root2.key
openssl req \
-x509 \
-new \
-key root2.key \
-sha256 \
-days 30 \
-config root2.cnf \
-out root2.pem
# Trusted, correct-name leaf.
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out api.key
openssl req \
-new \
-key api.key \
-config api.cnf \
-out api.csr
openssl x509 \
-req \
-in api.csr \
-CA root.pem \
-CAkey root.key \
-days 7 \
-sha256 \
-extfile api.ext \
-extensions server_cert \
-out api.pem
# Trusted, wrong-name leaf.
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out other.key
openssl req \
-new \
-key other.key \
-config other.cnf \
-out other.csr
openssl x509 \
-req \
-in other.csr \
-CA root.pem \
-CAkey root.key \
-days 7 \
-sha256 \
-extfile other.ext \
-extensions server_cert \
-out other.pem
# Correct-name leaf from a different, untrusted root.
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out api-untrusted.key
openssl req \
-new \
-key api-untrusted.key \
-config api.cnf \
-out api-untrusted.csr
openssl x509 \
-req \
-in api-untrusted.csr \
-CA root2.pem \
-CAkey root2.key \
-days 7 \
-sha256 \
-extfile api.ext \
-extensions server_cert \
-out api-untrusted.pem
chmod 600 root.key root2.key api.key other.key api-untrusted.keyThe seven-day leaf and 30-day root periods are merely documented lab choices to keep throwaway material short-lived. They imply nothing about public-CA lifetime requirements.
OpenSSL’s openssl-req documentation likewise documents explicit request configuration and extension handling, including -addext; the laboratory uses explicit files so the generated fixture is reviewable and not dependent on hidden global configuration.
Inspect each signed certificate before serving it:
for cert in root.pem root2.pem api.pem other.pem api-untrusted.pem; do
printf '\n=== %s ===\n' "$cert"
openssl x509 \
-in "$cert" \
-noout \
-sha256 \
-fingerprint \
-subject \
-issuer \
-dates
openssl x509 \
-in "$cert" \
-noout \
-ext subjectAltName 2>/dev/null || true
openssl x509 \
-in "$cert" \
-noout \
-ext basicConstraints \
-ext keyUsage \
-ext extendedKeyUsage 2>/dev/null || true
doneThe commissioning run observed these three leaf fingerprints:
Fixture | SAN | Issuer | Observed SHA-256 leaf fingerprint | |||||||||
Correct trusted | api.lab.test | trusted lab root | 61:62:2C:72:42:B2:43:01:DD:52:11:63:38:E1:F4:74:D0:EA:B1:1B:9F:D1:F1:AF:8E:33:51:D9:31:AE:5D:9F | |||||||||
Wrong-name trusted | other.lab.test | trusted lab root | 67:18:23:F9:18:1A:56:FB:8D:A4:A9:2A:78:F3:7A:E9:8B:04:4F:FD:2B:05:5D:30:C1:6B:3F:14:68:E9:53:72 | |||||||||
Correct-name untrusted | api.lab.test | untrusted lab root | 32:C3:C8:3B:BA:5F:F0:4C:3A:87:7A:08:FA:50:6F:03:67:73:69:B6:F0:D3:2D:4C:A5:19:69:91:AB:4F:E0:C9 | |||||||||
Freshly generated certificates should not reproduce those fingerprints. Their purpose is to tie this run’s server PID and verifier logs to the exact leaf that was presented.
Serve one known certificate at a time
Create one deterministic HTTP response. OpenSSL 3.5 documents s_server -HTTP as a simple HTTP server mode in which files are served relative to the process’s working directory and may contain their own HTTP response headers. It also documents -accept for the listening host and port and -cert/-key for the server identity.
printf \
'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nConnection: close\r\n\r\nOK\n' \
> response.txtStart only one fixture on loopback:
start_server() {
label=$1
cert=$2
key=$3
openssl s_server \
-accept 127.0.0.1:8443 \
-cert "$cert" \
-key "$key" \
-HTTP \
>"evidence/server-${label}.stdout" \
2>"evidence/server-${label}.stderr" &
SERVER_PID=$!
printf '%s\n' "$SERVER_PID" > "evidence/server-${label}.pid"
# Linux readiness check: verify that this PID owns the loopback listener.
for in $(seq 1 50); do
if ss -ltnp 2>/dev/null |
grep -F '127.0.0.1:8443' |
grep -F "pid=$SERVERPID" >/dev/null; then
break
fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
printf 'Server exited before readiness\n' >&2
return 1
fi
sleep 0.05
done
ss -ltnp |
grep -F '127.0.0.1:8443' \
>"evidence/server-${label}.listener.txt"
}Do not interpret a client failure until you prove that the intended certificate swap occurred. A stale s_server, another process already occupying 8443, or a server launched with the previous leaf invalidates the comparison even if the client log looks plausible.
A presentation-only inspection can capture the peer leaf independently of the gate:
printf \
'GET /response.txt HTTP/1.1\r\nHost: api.lab.test\r\nConnection: close\r\n\r\n' |
openssl s_client \
-connect 127.0.0.1:8443 \
-servername api.lab.test \
-showcerts \
> evidence/presentation.stdout \
2> evidence/presentation.stderr
awk '
/-----BEGIN CERTIFICATE-----/ { keep=1 }
keep { print }
/-----END CERTIFICATE-----/ { exit }
' evidence/presentation.stdout > evidence/presented.pem
openssl x509 \
-in evidence/presented.pem \
-noout \
-sha256 \
-fingerprint \
-subject \
-issuer \
-ext subjectAltName \
> evidence/presented.inventory.txtThis extraction is inventory evidence, not an acceptance predicate. -showcerts is not chain verification.
In the reproduced run, the wrong-name server PID was 377, the correct-name trusted server PID was 415, and the untrusted-root server PID was 439. For each server, the fingerprint extracted from the live TLS connection matched the pre-recorded leaf fingerprint above. That correspondence is what permits the later verifier result to be attributed to the intended fixture.
Stop each fixture cleanly before changing certificates:
stop_server() {
label=$1
pid=$(cat "evidence/server-${label}.pid")
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid"
wait "$pid" || true
fi
if kill -0 "$pid" 2>/dev/null; then
printf 'Lab server PID %s still alive\n' "$pid" >&2
return 1
fi
}Do not run pkill openssl, killall openssl, or any other broad cleanup that could affect unrelated processes.
The complete cycle for the trusted wrong-name control is:
start_server wrong other.pem other.key
# inspect live leaf, run probes, collect evidence here
stop_server wrongThen repeat with api.pem/api.key, then api-untrusted.pem/api-untrusted.key. Hold the address, port, SNI and expected hostname constant. The certificate/trust relationship is the variable.
That is the core experimental design: 127.0.0.1:8443 does not move, api.lab.test does not move, and the verifier command does not silently mutate between matrix cells.
Run the diagnostic command without a strict verdict
Begin with the kind of command commonly used for troubleshooting:
openssl s_client \
-connect 127.0.0.1:8443 \
-servername api.lab.test \
-showcertsEach flag proves something narrower than a release gate needs.
-connect 127.0.0.1:8443 controls the network destination. It does not declare that the certificate should represent 127.0.0.1, nor does it perform the DNS-name policy required here.
-servername api.lab.test sends the SNI extension. According to the OpenSSL 3.5 s_client documentation, SNI is placed in the ClientHello with this option. That can cause a virtual-hosted server to choose the certificate associated with api.lab.test; it is not itself a certificate-name comparison.
-showcerts displays the certificates the server sent. OpenSSL explicitly warns that this list is not a verified chain.
The diagnostic client also has intentionally permissive error behavior. OpenSSL says that when verification is enabled without -verify_return_error, verification continues after errors so multiple certificate problems can be examined; it further notes that this means the connection does not fail because of a server-certificate verification failure. -verify_return_error, by contrast, causes verification errors to be returned and will typically abort the handshake.
The wrong-name certificate is therefore a valuable negative control. It is not advice to weaken production verification. Its purpose is to reveal whether the check contains a hostname predicate at all.
In the commissioning reproduction, the diagnostic command was run while the server presented other.pem, whose SAN was only DNS:other.lab.test.
The observed process status was 0.
Because the trusted lab root had not been supplied to this diagnostic command, its logs included:
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificateand later:
Verification error: unable to verify the first certificate
Verify return code: 21 (unable to verify the first certificate)Yet the actual OpenSSL process exited successfully.
That is precisely why grepping the output for a scary-looking string is an inferior acceptance mechanism. A wrapper that ignores the subprocess status could fail open; a wrapper that treats any line containing CONNECTED as success could also fail open. Conversely, a wrapper that expects one exact numeric diagnostic could become brittle between builds.
Now add the trusted root but still omit hostname verification:
openssl s_client \
-connect 127.0.0.1:8443 \
-CAfile root.pem \
-servername api.lab.test \
-showcertsThis is the cleaner negative control, because chain trust is no longer the distraction. The server still presents other.lab.test, but its issuer is trusted.
The commissioning reproduction observed:
Verify return code: 0 (ok)and an OpenSSL process exit status of 0.
That result answers a common ambiguity directly: supplying the correct CA file and SNI does not, by itself, enforce api.lab.test as the certificate identity. The command accepted the chain because the root was trusted, while no hostname predicate had been requested.
OpenSSL’s verification-options documentation identifies -CAfile as an input that loads trusted certificates and -verify_hostname as the separate hostname-matching option.
This result should trigger repair of the probe, not replacement of the wrong-name fixture. A checker that accepts both the positive certificate and the trusted wrong-name certificate is unqualified for a DNS-identity release gate.
Add explicit trust and hostname rejection
The strict form is:
openssl s_client \
-connect 127.0.0.1:8443 \
-CAfile root.pem \
-servername api.lab.test \
-verify_hostname api.lab.test \
-verify_return_error \
-showcertsEach verification field should be reviewed independently.
-CAfile root.pem identifies the trust input. OpenSSL’s verification documentation says -CAfile loads the supplied certificate or certificates as trusted material. Do not replace this lab-local file by modifying the host’s system trust store; the experiment needs an explicit, digestible trust input.
-servername api.lab.test sends SNI. It controls server-side certificate selection where SNI is relevant. It is not the acceptance predicate.
-verify_hostname api.lab.test asks OpenSSL to verify the certificate against the intended DNS hostname. OpenSSL 3.5 documents it separately from SNI.
-verify_return_error changes the operational verdict: the s_client documentation says verification errors are returned rather than merely continued through, and this will typically abort the TLS handshake.
Against the trusted wrong-name leaf, the commissioning run observed an OpenSSL exit status of 1 and the relevant diagnostic:
verify error:num=62:hostname mismatchfollowed by a certificate-verification failure. This exact numeric value is reported only because it was observed on /usr/bin/openssl OpenSSL 3.5.5 in this fixture. The gate policy should classify the failure semantically as a hostname-verification rejection; it should not assume that an undocumented wrapper around every future build must emit the same complete transcript.
Against the trusted correct-name leaf, the same strict command observed exit status 0 and:
Verify return code: 0 (ok)The live leaf SAN was DNS:api.lab.test, its issuer was the trusted lab root, and its fingerprint matched the pre-run certificate inventory.
Against the correct-name certificate issued by root2.pem, the same strict command observed exit status 1. The name was right, but the declared trust input was deliberately root.pem. The diagnostic was an issuer/path-validation failure:
verify error:num=20:unable to get local issuer certificateThis third fixture prevents a shallow implementation from equating “name matches” with “certificate is accepted.” The probe must enforce both trust and identity.
An independent certificate verification command can sanity-check the fixtures before the network experiment:
openssl verify \
-CAfile root.pem \
-verify_hostname api.lab.test \
api.pem
openssl verify \
-CAfile root.pem \
-verify_hostname api.lab.test \
other.pem
openssl verify \
-CAfile root.pem \
-verify_hostname api.lab.test \
api-untrusted.pemIn the commissioning run, the first returned success; the second failed for hostname mismatch; the third failed because the issuer was not available from the declared trusted root. Those checks establish that the fixtures have the intended static properties, but they do not replace the s_client network test because the release question includes what the server actually presents.
Process handling is just as important as flags. Do not write:
# WRONG FOR A GATE:
openssl s_client ... 2>&1 | tee probe.log | grep 'Verify return code: 0'
echo "$?"Without deliberate shell handling, $? here represents the final pipeline command, not necessarily openssl. Even pipefail does not solve every evidence problem if the automation later converts the result to Boolean text or loses the exact child status.
Use a bounded subprocess harness with separate stdout, stderr and status. This Python harness also supplies a complete HTTP request:
#!/usr/bin/env python3
import json
import subprocess
import sys
from pathlib import Path
usage = "usage: run_sclient.py LABEL TIMEOUT_SECONDS -- COMMAND..."
try:
, label, timeouttext, separator, *cmd = sys.argv
except ValueError:
raise SystemExit(usage)
if separator != "--" or not cmd:
raise SystemExit(usage)
timeout_s = float(timeout_text)
request = (
b"GET /response.txt HTTP/1.1\r\n"
b"Host: api.lab.test\r\n"
b"Connection: close\r\n"
b"\r\n"
)
evidence = Path("evidence")
evidence.mkdir(exist_ok=True)
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
timed_out = False
try:
stdout, stderr = proc.communicate(
input=request,
timeout=timeout_s,
)
except subprocess.TimeoutExpired:
timed_out = True
proc.kill()
stdout, stderr = proc.communicate()
record = {
"label": label,
"command": cmd,
"timeout_seconds": timeout_s,
"timed_out": timed_out,
"openssl_returncode": proc.returncode,
}
(evidence / f"{label}.stdout").write_bytes(stdout)
(evidence / f"{label}.stderr").write_bytes(stderr)
(evidence / f"{label}.json").write_text(
json.dumps(record, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(record, sort_keys=True))
if timed_out:
raise SystemExit(124)
if proc.returncode < 0:
raise SystemExit(125)
raise SystemExit(proc.returncode)Invoke the strict probe through it:
python3 ./run_sclient.py strict-right 5 -- \
"$(command -v openssl)" s_client \
-connect 127.0.0.1:8443 \
-CAfile root.pem \
-servername api.lab.test \
-verify_hostname api.lab.test \
-verify_return_error \
-showcerts \
-ign_eof-ign_eof is useful in this controlled HTTP exchange because it prevents stdin EOF from immediately initiating shutdown; the server’s Connection: close response can then determine the read lifecycle, while the outer five-second harness still bounds a peer that fails to close. OpenSSL documents -ign_eof as inhibiting shutdown when input reaches EOF.
The commissioning reproduction separately reran the positive fixture this way. It observed exit 0 and received the complete controlled response:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 3
Connection: close
OKDo not use s_client -timeout as a generic TCP timeout. OpenSSL 3.5 explicitly documents s_client -timeout as enabling send/receive timeout on DTLS connections. The wall-clock limit above belongs to the outer harness.
A harness timeout and a hostname rejection both prevent a positive verdict, but they mean different things. A hostname rejection demonstrates that verification ran and rejected the identity. A harness timeout establishes only that the run did not complete within its bound. The latter is hold/failure of the probe run, not proof of hostname validation.
Reconcile the positive and negative matrix
The matrix must include all four controls. A checker that merely fails on everything is not qualified.
The commissioning results were:
Control | Presented identity/trust | Strict s_client | Observed process result | Interpretation |
Trusted right name | SAN api.lab.test, trusted root.pem | Must accept | 0 | Positive control passed |
Trusted wrong name | SAN other.lab.test, trusted root.pem | Must reject | 1 | Hostname rejection |
Untrusted right name | SAN api.lab.test, signed by root2.pem | Must reject | 1 | Trust rejection |
Transport unavailable | no listener | Must not pass | 1 | Connection failure, not certificate evidence |
For the transport-unavailable control, the run first confirmed there was no listener on 127.0.0.1:8443. The s_client process then returned 1 with a connection-refused diagnostic. That result belongs in a different classification from either certificate negative. No TLS peer was reached, so no hostname comparison against a presented certificate could have been demonstrated.
The strict probe is qualified only if the pattern is:
PASS trusted right name
FAIL trusted wrong name
FAIL untrusted right name
FAIL transport unavailableIf the pattern is:
FAIL
FAIL
FAIL
FAILdo not congratulate the gate for being “secure.” The positive fixture proves whether the checker can recognize the intended relationship. Rejecting everything may indicate a bad CA file, wrong path, expired fixture, broken server, unsupported option, stale listener, bad wrapper or unrelated transport failure.
An independent curl comparison is useful as supplementary evidence because curl’s default HTTPS verification model includes both CA validation and verification that the certificate is issued for the hostname in the URL. curl’s official TLS Certificate Verification documentation says curl verifies certificates by default using the CA trust relationship and the server name in the URL; it documents --cacert for a custom CA file and warns against disabling verification with -k/--insecure.
Keep api.lab.test in the URL and route it to loopback with --resolve:
curl -q \
--noproxy '*' \
--cacert "$PWD/root.pem" \
--resolve api.lab.test:8443:127.0.0.1 \
--connect-timeout 2 \
--max-time 5 \
--silent \
--show-error \
--verbose \
https://api.lab.test:8443/response.txtThe official curl command-line manual documents --resolve host:port:addr as a way to supply an address mapping while retaining the hostname in the URL, and shows example.com being resolved to 127.0.0.1. It separately documents --connect-timeout as bounding the connection phase, including requested TCP/TLS handshakes, and --max-time as limiting each transfer.
--noproxy '*' keeps this loopback comparison out of an environment-configured proxy path. -q disables curl’s normal config-file loading, helping prevent an unnoticed .curlrc from modifying the experiment. The curl manual notes that curl normally searches for a default config file unless that behavior is disabled.
Do not replace the URL with https://127.0.0.1:8443/. That would change the reference identity from the required DNS name to an IP address. Do not add -k; curl explicitly identifies that option as disabling peer verification and recommends against using it in production.
The commissioning curl build was recorded as:
curl 8.10.1
libcurl/8.10.1
TLS backend: OpenSSL/3.5.5The curl manual says --version reports the curl/libcurl version and linked third-party libraries, which is why that output belongs in the evidence package.
Observed curl results were:
Fixture | Observed curl exit | Relevant observed result |
Trusted right name | 0 | SAN matched; certificate verification OK |
Trusted wrong name | 60 | SAN did not match api.lab.test |
Untrusted right name | 60 | unable to get local issuer certificate |
Transport unavailable | 7 | could not connect |
Those exact numbers belong to this recorded curl build; release policy should classify the semantics, not infer that all curl versions, TLS backends or other clients expose identical diagnostics. curl is supplementary evidence for the backend that was actually recorded. It does not prove OpenSSL CLI defaults, browser behavior or every application’s TLS policy.
This narrow comparison belongs beside broader API security and observability, but it should remain a probe-qualification exercise rather than another general security checklist.
When a field fails, repair that field first.
If the trusted wrong-name fixture passes, repair the probe:
# Before: insufficient for hostname-gating
openssl s_client \
-connect 127.0.0.1:8443 \
-CAfile root.pem \
-servername api.lab.test
# After: explicit identity gate
openssl s_client \
-connect 127.0.0.1:8443 \
-CAfile root.pem \
-servername api.lab.test \
-verify_hostname api.lab.test \
-verify_return_errorIf the correct-name leaf is rejected for issuer/trust reasons, inspect the -CAfile path and its digest. Do not “repair” that problem by importing the CA system-wide for this experiment.
If the served leaf itself has other.lab.test when the endpoint contract says api.lab.test, repair the serving identity. The checker should continue rejecting the broken endpoint until the correct leaf is actually presented.
If connection routing is wrong, repair the routing/listener field; do not weaken verification to make the request succeed.
This same field-oriented debugging discipline is useful in secure database integration context: endpoint, credential/trust material and application identity are separate inputs and should remain separately observable.
After any repair, rerun the full matrix. Never preserve only the new positive result. The old failure is part of the audit trail; the negative controls demonstrate that the fix did not accidentally create a fail-open checker.
Package the release evidence around immutable or digestible artifacts:
batch
fixture commit
OpenSSL executable path
openssl version -a
OS and shell
connection address
SNI
verification hostname
CA file path
CA SHA-256 digest
presented leaf fingerprint
presented subject/issuer/SAN
server PID
full client argv
stdout file
stderr file
actual client process status
outer timeout flag/status
curl --version
curl comparison status
decisionA practical decision table is:
Evidence condition | Decision | ||||||||
Positive passes; wrong-name and untrusted controls reject for their intended reasons; transport control does not pass | Accept qualified probe | ||||||||
Wrong-name passes because hostname verification is missing | Repair probe settings | ||||||||
Endpoint presents wrong SAN under declared production identity | Block endpoint | ||||||||
Endpoint chain does not reach declared trust input | Block or repair serving/trust configuration according to ownership | ||||||||
Probe times out, stale PID is possible, certificate swap is unproven, status is lost, or docs/implementation conflict | Hold evidence | ||||||||
Wrapper accepts nonzero OpenSSL status or replaces it with grep/tee success | Repair wrapper and requalify | ||||||||
All certificate controls fail, including the valid positive | Hold/repair; probe is not qualified | ||||||||
Certificate owners and automation owners have different responsibilities. The certificate/service owner owns the SAN, issuer relationship and certificate actually served. The automation owner owns -CAfile, SNI, -verify_hostname, -verify_return_error, timeout handling, process-status preservation and evidence retention. A change to either side can invalidate prior qualification.
Changes to verification flags, OpenSSL executable/build, trust inputs, connection wrapper or parsing logic should therefore trigger requalification. Treat the checker itself as a controlled component. Operational evidence and monitoring are relevant here because the decision depends on preserving enough state to distinguish an identity failure from a transport or wrapper failure.
For teardown, archive only nonsecret evidence and public certificate material, then remove only the directory marked as lab-owned. Private keys must not enter the evidence archive.
# LAB-ONLY CLEANUP. Do not adapt this path check casually.
set -euo pipefail
BATCH='2026-09-25-batch2'
LAB="$PWD/tls-probe-${BATCH}"
ARCHIVE="$PWD/${BATCH}-nonsecret-evidence.tar.gz"
if [ ! -d "$LAB" ] ||
[ ! -f "$LAB/.lab-owner-marker" ] ||
[ "$(cat "$LAB/.lab-owner-marker")" != "$BATCH" ]; then
printf 'Refusing cleanup: lab ownership marker failed\n' >&2
exit 2
fi
# Stop only the PID recorded by this laboratory.
if [ -f "$LAB/evidence/server.pid" ]; then
pid=$(cat "$LAB/evidence/server.pid")
if kill -0 "$pid" 2>/dev/null; then
args=$(ps -p "$pid" -o args= || true)
case "$args" in
"openssl s_server""$LAB"*)
kill -TERM "$pid"
wait "$pid" || true
;;
*)
printf 'Refusing to kill unexpected PID %s\n' "$pid" >&2
exit 3
;;
esac
fi
fi
if [ -e "$ARCHIVE" ]; then
printf 'Refusing existing archive: %s\n' "$ARCHIVE" >&2
exit 4
fi
tar -C "$LAB" -czf "$ARCHIVE" \
.lab-owner-marker \
.gitignore \
root.cnf root2.cnf api.cnf other.cnf \
api.ext other.ext response.txt \
root.pem root2.pem api.pem other.pem api-untrusted.pem \
evidence
sha256sum "$ARCHIVE"
# Only after all ownership checks and archive creation succeed.
rm -rf -- "$LAB"In the commissioning reproduction, the nonsecret archive was created only after confirming no listener remained on 127.0.0.1:8443; its observed SHA-256 digest was:
394f84ae3ff07ec0caf252cd551f446a41d1b59f7a9ca23559595c83f8b6a3ddThe disposable lab directory was then removed. That digest identifies this execution archive only; regenerated material should differ.
This qualification does not establish revocation-policy completeness, certificate pinning, mTLS authorization, service-mesh correctness, cipher performance, ACME renewal behavior, public-certificate scheduling, CA/B Forum lifecycle policy, or compatibility with every TLS client. It qualifies one probe against one explicit trust-and-identity contract.
Build cloud-security validation foundations with Refonte Learning
The operational lesson is broader than remembering two OpenSSL switches: security evidence is useful only when the checker’s own acceptance and rejection paths are understood.
For this laboratory, the evidence is strong because the identity contract is explicit, routing and SNI are separated from hostname verification, trust is supplied through one recorded file, the wrong-name and untrusted fixtures exercise independent failure modes, the real child-process status is preserved, transport failure is classified separately, and an unrelated TLS client is used only as supporting evidence, not as a substitute.
The OpenSSL 3.5 documentation baseline supports the critical semantics: s_client is a diagnostic client; -servername sends SNI; -verify_hostname is the explicit hostname predicate; -verify_return_error makes verification errors operationally rejecting; -showcerts is not a verified chain; and s_client -timeout is not the general TCP timeout that a release harness needs.
For commissioning batch 2026-09-25-batch2, the reproduced OpenSSL 3.5.5 matrix showed the intended result: the strict probe accepted the trusted api.lab.test certificate, rejected the equally trusted other.lab.test certificate, rejected the correct-name certificate from the untrusted second CA, and did not confuse an unavailable transport with certificate evidence. The earlier SNI-only trusted test accepted the wrong-name certificate, proving why that form was not qualified as the release gate.
Engineers building the surrounding cloud-security judgment may also review Refonte Learning’s Cloud Security Engineer Essentials. As described on the programme page accessed September 25, 2026, the programme runs for three months at 10–12 hours per week and lists data encryption, threat detection, incident-response planning, and cloud monitoring/logging among its competency areas. Those are relevant foundations for evaluating evidence-driven security controls such as this one, without implying that the programme specifically teaches this OpenSSL/curl laboratory or promises any particular employment outcome.
