Skip to content

abctl configure claude-code status cannot detect a stale or mismatched TLS-bridge CA #1105

Description

@mrsabath

Summary

abctl configure claude-code status reports only which env keys are set. It never stats the CA file, parses the certificate, checks expiry, or compares the CA against what the running proxy actually signs with. So the most common real-world TLS-bridge failure is invisible to the one command users are told to run.

The gap is self-evident from the code: claudeCodeStatus (authbridge/cmd/abctl/cmd_claudecode.go:567-592) iterates managedKeys, prints each as KEY=value or KEY (unset), and concludes enabled when all are present:

if set == len(managedKeys) {
    fmt.Fprintf(stdout, "enabled in %s\n", settingsPath)

A config pointing NODE_EXTRA_CA_CERTS at a deleted, unreadable, or wrong CA reports enabled.

The irony worth fixing: enable already advises users to check with this exact command for a condition it cannot detect — cmd_claudecode.go:427 tells the user "Start Cortex, then check with: abctl configure claude-code status".

Why it matters — the failure this hid

Debugged on a laptop (see #1104 for the installer-side fix). Two proxies from different installs were fighting over :47600. The one holding the port signed with ca_dir=<checkout>/.cortex/ca, while every client trusted ~/.cortex/cadifferent CA fingerprints. Intercepted requests failed certificate verification and surfaced to the user as:

API Error: Unable to connect to API: Self-signed certificate detected.

Throughout, status would have reported enabled: all four keys were set, and the path existed and was readable. The reinstall had left four independent CAs on disk, all with CN=authbridge-tls-bridge-ca and all with different fingerprints. Nothing in the tooling reconciles or reports on them.

Proposed checks

Extend claudeCodeStatus to report, per CA-bearing key:

  1. Exists / readableos.Stat plus a read attempt. Note NODE_EXTRA_CA_CERTS extends Node's roots, so an unreadable file does not break TLS: Node prints Warning: Ignoring extra certs from ..., load failed and continues. Verified — a request still returned 200. That silence is exactly why it needs surfacing here.

  2. Parses as a certificate, and whether it is the bridge CA — reuse parseBridgeCA / parseBridgeCAPEM (cmd/authbridge-proxy/local.go:144-160), which return nil unless Subject.CommonName == bridgeCACommonName.

  3. Not expired / not near expiry — the CA has a 365-day lifetime and the proxy renews within 30 days of NotAfter (caRenewBefore, authlib/tlsbridge/ca.go:196; caNeedsRenewal, :239-253). Renewal is evaluated at startup only, so a long-running proxy sails past the window — worth warning about.

  4. Matches the CA the proxy is actually configured with — compare against tls_bridge.ca_dir from the loaded config. This is the check that catches the bug above.

  5. (Optional, highest value) Matches the CA the live listener presents. A config-level comparison still misses a foreign proxy holding the port. Note the retrieval detail: a forward proxy presents no certificate on a bare TCP connect — the CA must be read through a CONNECT tunnel, where the bridge sends it as the second cert in the chain. Verified equivalent to this shell:

    echo | openssl s_client -proxy 127.0.0.1:47600 -connect example.com:443 \
      -servername example.com -showcerts 2>/dev/null \
      | awk '/BEGIN CERT/{n++} n==2{print} /END CERT/{if(n==2) exit}' \
      | openssl x509 -noout -fingerprint -sha256

    Because this reaches out to a third-party host, it should probably be opt-in behind a flag.

Reusable pieces

What Where Note
FingerprintSHA256(crt *x509.Certificate) string authlib/tlsbridge/ca.go:267 Exported. Uppercase colon-separated hex, deliberately matching openssl x509 -noout -fingerprint -sha256 so a human can compare output directly.
staleClientCAWarning(...) cmd/authbridge-proxy/local.go:106-137 Closest existing analogue. Its doc comment (:85-105) explains why it compares certificates, not paths — the configured file is often a system root bundle behind a corporate proxy, and path comparison both false-fired and gave wrong advice. Unexported in package main: lift the pattern, not the symbol.
parseBridgeCA / parseBridgeCAPEM cmd/authbridge-proxy/local.go:144-160 The CN filter is what keeps diagnostics silent on foreign certs. Also unexported.
caNeedsRenewal / caRenewBefore authlib/tlsbridge/ca.go:239-253, :196 Expiry-window logic.

Gotchas for whoever picks this up

  • Compare certificates, not paths. Already learned once in staleClientCAWarning — read its doc comment before designing the comparison.

  • Fingerprints, not issuer names. All four leftover CAs on the reproducing machine shared CN=authbridge-tls-bridge-ca. The name proves nothing; only the fingerprint (or serial) discriminates. Each regeneration mints a new random 128-bit serial (ca.go:53), so serial works too.

  • bundle.crt mtime is not a staleness signal. EnsureTrustBundle recomputes it on every start but writes only on content change (authlib/tlsbridge/bundle.go:151-153), and concatCertDir sorts filenames to keep the bytes stable across boots. Compare content, not timestamps.

  • ca.crt always lands before bundle.crtEnsureFileSource (cmd/authbridge-proxy/main.go:716) then EnsureTrustBundle (:769). enable already relies on this ordering to warn about the two files separately (cmd_claudecode.go:436-444).

  • The two kinds of CA var are not interchangeable. NODE_EXTRA_CA_CERTS extends the trust store, so ca.crt is correct for it. SSL_CERT_FILE / REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE replace it, so they need bundle.crt (CA + system roots) or ordinary HTTPS stops verifying. Any "here's the fix" output must respect that distinction.

  • When no system root store is found, EnsureTrustBundle deliberately leaves an old bundle in place (bundle.go:136-139), which on a renewal boot pins a stale bridge CA — called out at main.go:760-768. A status check should be able to say this happened.

  • Exit code. claudeCodeStatus currently always returns 0. Decide deliberately whether a detected mismatch should make it non-zero — useful for scripting, but a behavior change for anything parsing it today.

Repro

  1. Start a proxy with --local from some checkout, so it mints its own CA under <checkout>/.cortex/ca.
  2. Run abctl configure claude-code enable against ~/.cortex, so clients are pointed at ~/.cortex/ca/ca.crt.
  3. Confirm the two ca.crt fingerprints differ (openssl x509 -noout -fingerprint -sha256 -in ...).
  4. abctl configure claude-code status → reports enabled, with no indication anything is wrong.
  5. Any intercepted request fails certificate verification.

Scope

Diagnostics only. Deliberately excluded:

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions