WSL · Corporate proxy · TLS

Corporate TLS interception in WSL — “self signed certificate in chain”

Diagnosing and fixing TLS-interception (MITM proxy) breakage in WSL. Written after fixing exactly this on an Ubuntu 26.04 WSL2 distro, where in turn opencode, a remote MCP server, and the Claude Code CLI all failed with Error: self signed certificate in certificate chain — one root cause, three symptoms.

All corporate identifiers here are redacted: <CORP-ROOT-CA> is the self-signed corporate root, proxy.<corp-domain> the proxy’s intermediate. Public SaaS hostnames are kept as-is — they are needed to explain the selective-interception behaviour and identify no one.

The corporate quirk

The network runs a TLS-intercepting proxy. Every intercepted connection is re-signed on the fly:

leaf   CN=<the site you asked for>
  issued by  CN=proxy.<corp-domain>        (intermediate)
    issued by  CN=<CORP-ROOT-CA>           (self-signed root, not publicly trusted)

Because <CORP-ROOT-CA> is not in any trust store, every TLS client reports “self signed certificate in certificate chain” (OpenSSL verify code 19, Node SELF_SIGNED_CERT_IN_CHAIN).

The confusing part

Interception is selective. Only some hosts are rewritten; others pass through with their genuine public chains.

InterceptedUntouched
opencode.ai, models.opencode.ai, api.opencode.airegistry.npmjs.org
models.devgithub.com
api.anthropic.comraw.githubusercontent.com
the remote MCP endpoint

So npm install and git clone work fine while opencode is dead. Do not conclude “the network is fine” from a working npm/git — always probe the host that is actually failing.

Everything in the left column shares one cause. Three separate-looking symptoms turned up over this investigation — opencode failing, the MCP server not connecting, and the Claude Code CLI erroring — and all three were this same proxy. Suspect one root cause before theorising about three.

Three traps that make the obvious fix fail

1. Exporting CAs from the Windows cert store may not contain the proxy CA

The intuitive route — dump the Windows Root/CA stores, import into WSL — silently produced only public CAs and none of the corporate ones. Filtering with “exclude Windows built-in roots” does not guarantee the interception CA is present in the store at all.

Fix

Capture the CA off the wire while connected to the corporate network, using openssl s_client -showcerts. That is what check-ssl.sh does, and it is always correct because it records exactly what the proxy presented.

2. opencode is a Bun-compiled binary and ignores the system trust store

Its stack traces show /$bunfs/…. Bun does not read /etc/ssl/certs and does not honour SSL_CERT_FILE. It honours only NODE_EXTRA_CA_CERTS.

Consequence: update-ca-certificates alone fixes curl/git/python but not opencode. The CA must land in both places:

TargetFixes
/usr/local/share/ca-certificates/corp/*.crtupdate-ca-certificatescurl, git, python, openssl
/usr/local/share/corp-bundle.pemNODE_EXTRA_CA_CERTSnode, Bun, opencode

Claude Code ships as a native compiled binary too and had NODE_EXTRA_CA_CERTS in its environment. Which of the two stores it actually reads was never determined — both were updated within two seconds of each other, so the observation cannot separate them. Install to both and the question does not matter.

3. A process already running keeps the OLD trust store

Both the system bundle and NODE_EXTRA_CA_CERTS are read once, when a process first builds its TLS trust store. Replacing those files afterwards does nothing for anything already running.

Observed case

After the CA was installed, opencode — relaunched afterwards — worked on the corporate network, while the Claude Code CLI session kept failing with the same self-signed error. That session had started about 7 minutes before the bundle was replaced. Its NODE_EXTRA_CA_CERTS pointed at the correct path; the file’s contents at the moment it read them were still the two useless public CAs from the failed first attempt. Restarting Claude Code fixed it with no further changes.

Symptom to recognise: on one network, some tools work and others fail, with identical env vars. Compare process start time against the bundle’s mtime:

ps -o pid,lstart -p <pid>
stat -c '%y %n' /usr/local/share/corp-bundle.pem /etc/ssl/certs/ca-certificates.crt

If the process is older than the file, restart it — that is the whole fix. It also explains the confusing “works on mobile hotspot, fails on corporate” pattern: an unfiltered network needs only the public roots the stale bundle already had, so the staleness stays invisible until you hit the proxy.

Procedure

# 1) ON the corporate network — diagnose + capture. Read-only, no sudo.
bash check-ssl.sh
#    -> ssl-report-<timestamp>/{report.txt,captured-cas/,captured-bundle.pem}

# 2) Install. Works on any network, needs sudo.
bash install-captured-ca.sh ssl-report-<timestamp>

# 3) Reload the shell AND restart every long-running client
#    (opencode, Claude Code, editors, language servers, dev servers).
#    See trap 3 - a running process keeps the trust store it started with.
exec $SHELL -l

# 4) Verify ON the corporate network
curl -sSI https://models.opencode.ai/api.json | head -1
opencode run 'hi'
opencode mcp list          # remote MCP servers go through the same proxy

install-captured-ca.sh skips public CAs by issuer-name match and installs only the corporate ones, so the trust store does not get polluted with redundant copies of public roots.

Reading check-ssl.sh output

Per host it prints four client results. The important one:

node (builtin roots)   : FAIL SELF_SIGNED_CERT_IN_CHAIN   <- what Bun/opencode roughly sees

node (builtin roots) runs with NODE_EXTRA_CA_CERTS/SSL_CERT_FILE stripped, approximating Bun’s embedded-roots-only view. If curl passes but that row fails, the problem is the runtime’s trust store, not the network.

Heuristic, not proof

The verdict line matches the top-of-chain issuer against a list of known public CAs. A proxy impersonating a public CA name would be mislabelled “public” — trust the client rows over the verdict.

MCP servers behind the proxy

A remote MCP server ("type": "remote" in the opencode config) is just an HTTPS endpoint, so it is subject to the same interception — plus proxy URL/category filtering, which the CA fix does not address.

Resolved case

An MCP server that connected on a mobile hotspot but not on the corporate network turned out to be the same root cause — its host was also intercepted, it just was not in the original probe list, so the first capture never covered it. Installing the corporate CA fixed it with no MCP-specific work. Lesson: enumerate every failing host before concluding the CA fix is complete.

check-ssl.sh discovers MCP URLs from the opencode config at runtime (it greps "url" values), so no private hostname is hard-coded. For each it reports DNS A/AAAA, TCP/443 reachability, an unauthenticated GET, and a JSON-RPC initialize POST.

No credentials are ever sent. An HTTP 401 with a JSON auth error is the success signal — it proves TLS and the HTTP round-trip completed end to end. Distinguish the failure modes by what comes back:

SymptomMeaning
401 + JSON auth errorendpoint reachable, transport fine
401/403 + HTML mentioning block/policy/filterproxy block page, not the server
GET status: FAILED with a cert errorTLS interception — re-run the CA capture/install
TCP/443 v4 : BLOCKEDfirewall drop, nothing to do with certificates
GET works but POST rpc failsproxy mangles POST or breaks SSE/streaming

Keep a known-good run from an unfiltered network and diff against the corporate run — the difference localises the fault immediately.

Note getent hosts prints the AAAA record first on dual-stack hosts, which looks like “IPv6 only” at a glance. Check getent ahostsv4 before blaming IPv6.

Gotchas

Security note

Trusting the corporate root means the proxy can decrypt TLS traffic to the intercepted hosts. That is a property of how the network is operated — these scripts only make the existing interception explicit rather than fatal. The alternative (disabling verification) is strictly worse: it accepts any certificate, not just the proxy’s.

Do not publish captures

Captured CA material identifies the organisation. Report folders should stay gitignored and must not be committed or shared. The scripts and this document contain no corporate hostname — check-ssl.sh discovers the MCP URL from the opencode config at runtime rather than hard-coding it.

Files

FilePurpose
check-ssl.shDiagnose + capture CAs off the wire, and probe MCP endpoints. Run on the affected network.
baseline-hotspot/Known-good run from an unfiltered network, for diffing. Gitignored.
corporate-run-fixed/Corporate-network run after the fix: interception present, everything verifying. The reference for “healthy on the filtered network”. Gitignored.
install-captured-ca.shInstall captured non-public CAs into system store + NODE_EXTRA_CA_CERTS bundle.