Added Phase 1-3 discovery timeline (certifi → .pem→.crt → cafile→capath), OpenSSL 3.x cafile limitation analysis, diagnostic matrix, test commands, version history. All 8 findings documented.
12 KiB
[DEF:ADR-0009:ADR]
@STATUS ACTIVE
@PURPOSE Define the strategy for corporate SSL certificate management across all LLM HTTP clients in ss-tools (httpx, requests, openai), covering system CA store, NSS database for Chromium/Playwright, and the LLM_SSL_VERIFY escape hatch.
@RELATION DEPENDS_ON -> [ADR-0001:ADR]
@RELATION DEPENDS_ON -> [ADR-0004:ADR]
@RELATION CALLS -> [docker/backend.entrypoint.sh]
@RELATION CALLS -> [backend/src/plugins/llm_analysis/service.py]
@RELATION CALLS -> [backend/src/plugins/translate/_llm_http.py]
@RELATION CALLS -> [backend/src/plugins/translate/preview_llm_client.py]
@RELATION CALLS -> [scripts/check_llm_certs.py]
@RATIONALE ss-tools operates in corporate environments with internal Certificate Authorities.
Three separate HTTP stacks are used: httpx (AsyncOpenAI in llm_analysis plugin),
requests (sync translate plugin), and Playwright Chromium (dashboard screenshots).
Each has its own CA trust store, requiring different installation strategies.
@REJECTED Centralizing all LLM calls into a single HTTP client — rejected because
llm_analysis uses async httpx+AsyncOpenAI for streaming and multiple concurrent calls,
while translate uses sync requests for simpler request/response. Merging would
require rewriting one or both, creating regression risk with no business value.
Problem
Corporate SSL certificates installed via update-ca-certificates into /etc/ssl/certs/ca-certificates.crt are NOT automatically trusted by:
- Python
requestslibrary — uses bundledcertifiCA bundle, not system store - Python
httpxlibrary — usescertifiby default whenverify=True - Playwright Chromium — uses NSS Shared DB (
~/.pki/nssdb), not OpenSSL store openssl s_client— hangs without stdin (needsecho \|orinput="")
This causes SSLError: CERTIFICATE_VERIFY_FAILED and ERR_CERT_AUTHORITY_INVALID even after correct system-wide CA installation.
Discovery Journey
Phase 1: certifi vs system CA (0.1.5)
requestsandhttpxusecertifibundle, not/etc/ssl/certs/ca-certificates.crt- Fix: return system CA path instead of
Truein all_get_verify()functions - Implemented in
service.py,_llm_http.py,preview_llm_client.py
Phase 2: .pem vs .crt extension (0.1.6)
update-ca-certificatesonly processes.crtfiles, silently ignores.pem- Downloaded certificates from PKI were saved as
.pem, skipped by update-ca-certificates - Result:
ca-certificates.crtwas empty (0 certs), hash symlinks existed but bundle was broken - Fix: save downloaded certs with
.crtextension, not.pem
Phase 3: cafile vs capath (0.1.7) — KEY DISCOVERY
openssl s_client -CAfile /etc/ssl/certs/ca-certificates.crt→ code 20 (intermediate CA ignored)openssl s_client -CApath /etc/ssl/certs/→ code 0 (chain built correctly)ssl.create_default_context(cafile=...)→ SSL errorssl.create_default_context(capath=...)→ HTTP 200requests.get(verify="/etc/ssl/certs/ca-certificates.crt")→ SSLErrorrequests.get(verify="/etc/ssl/certs/")→ HTTP 200
Root cause: OpenSSL 3.x treats non-self-signed certificates in -CAfile as trust anchors,
NOT as intermediates. -CApath (directory with hash symlinks) correctly builds chains using
all certificates found. The intermediate CA certs (Policy CA, RGM Issuing CA) are not
self-signed, so they are ignored in -CAfile but correctly used in -CApath.
Diagnostic matrix (verified on production server 2026-05-28):
| Method | cafile | capath |
|---|---|---|
| openssl s_client | code 20 FAIL | code 0 OK |
| httpx (SSLContext) | SSLError FAIL | HTTP 200 OK |
| requests (verify=) | SSLError FAIL | HTTP 200 OK |
Solution
Layer 1: System CA Store (OpenSSL)
- Entrypoint
install_certificates()copies.crtfiles fromCERTS_PATHvolume mount - Entrypoint
install_llm_ca_certs()downloads PEM/DER certificates fromLLM_CA_CERT_URLS - DER is auto-converted to PEM (
openssl x509 -inform DER -outform PEM) - Certificates are saved with
.crtextension (.pemis silently ignored byupdate-ca-certificates) update-ca-certificates --freshadds them to/etc/ssl/certs/ca-certificates.crt- Fallback: if
update-ca-certificatesmisses certs, they are appended toca-certificates.crtwith SHA256 fingerprint dedup - Hash symlinks created with collision support:
.0,.1,.2suffixes
Layer 2: Python HTTP Clients
| Library | File | Mechanism | Status |
|---|---|---|---|
httpx (AsyncOpenAI) |
service.py:LLMClient._get_ssl_verify() |
ssl.create_default_context(capath="/etc/ssl/certs/") |
✅ Works (0.1.7) |
requests (_llm_http.py) |
_get_verify() |
"/etc/ssl/certs/" (string path to dir) |
✅ Works (0.1.7) |
requests (preview_llm_client.py) |
_get_verify() |
"/etc/ssl/certs/" (string path to dir) |
✅ Works (0.1.7) |
Key insight: verify=True uses certifi, NOT system CA. verify=<cafile_path> ignores
intermediate CA in OpenSSL 3.x. verify=<capath_dir> works correctly.
Layer 3: NSS Database (Playwright Chromium)
- Entrypoint
install_ca_to_nss()imports PEM certs into~/.pki/nssdb/usingcertutil - NSS DB path format:
sql:$HOME/.pki/nssdb(SQLite prefix required, DBM not supported by Chromium) - Nickname format:
{dir_prefix}-{filename}(e.g.,llm-UC_RUSAL_Policy_CA) - Dedup by SHA256 fingerprint (not nickname), preventing duplicate imports
- Trust attributes:
"C,,"(trusted CA for TLS server certs) - Fixes
ERR_CERT_AUTHORITY_INVALIDin Playwright dashboard screenshots
Layer 4: LLM_SSL_VERIFY Escape Hatch
- Env var
LLM_SSL_VERIFY=falsedisables SSL verification entirely - Accepted values for false:
false,0,no,off(case-insensitive) - Default: enabled (returns capath-based SSLContext or path)
- Used by all three HTTP client implementations
- WARNING:
verify=Falseis for DIAGNOSTIC USE ONLY. Never leave in production.
Key Files
| File | Role |
|---|---|
docker/backend.Dockerfile |
Installs libnss3-tools (certutil), Playwright Chromium |
docker/backend.entrypoint.sh |
install_certificates, install_llm_ca_certs, install_ca_to_nss |
plugins/llm_analysis/service.py |
LLMClient._get_ssl_verify() → ssl.create_default_context(capath=...) |
plugins/translate/_llm_http.py |
_get_verify() → "/etc/ssl/certs/" |
plugins/translate/preview_llm_client.py |
_get_verify() → "/etc/ssl/certs/" |
scripts/check_llm_certs.py |
Full diagnostic: openssl, httpx, requests, NSS |
docker-compose.yml |
Passes LLM_SSL_VERIFY, LLM_CA_CERT_URLS |
docker-compose.enterprise-clean.yml |
Same as above |
.env.enterprise-clean |
Default values for both env vars |
How to Test Certificate Installation
On the server, after container restart:
docker cp scripts/check_llm_certs.py ss-tools-backend-1:/tmp/
docker compose -f docker-compose.enterprise-clean.yml \
--env-file .env.enterprise-clean exec backend \
python3 /tmp/check_llm_certs.py --target https://lite.ai.rusal.com
Expected diagnostic matrix:
openssl_default: ✅ code 0
openssl_capath: ✅ code 0
openssl_cafile: ❌ code 20 (expected — OpenSSL 3.x limitation)
httpx_capath: ✅ HTTP 200
httpx_cafile: ❌ SSL error (expected)
requests_capath: ✅ HTTP 200
requests_cafile: ❌ SSL error (expected)
Manually testing chain validity
# 1. Check individual certs
openssl x509 -in /usr/local/share/ca-certificates/custom/RUSAL_ROOT.crt -noout -subject -issuer
# 2. Verify full chain
openssl verify -CAfile /usr/local/share/ca-certificates/custom/RUSAL_ROOT.crt \
-untrusted /usr/local/share/ca-certificates/llm/UC_RUSAL_Policy_CA.crt \
/usr/local/share/ca-certificates/llm/UC_RUSAL_RGM_Issuing_CA.crt
# → OK
# 3. Connect with capath
echo | openssl s_client -connect lite.ai.rusal.com:443 \
-servername lite.ai.rusal.com \
-CApath /etc/ssl/certs/
# 4. Verify certificate count
grep -c "BEGIN CERTIFICATE" /etc/ssl/certs/ca-certificates.crt
Discovered Findings
Finding 1: certifi vs system CA
requests and httpx use certifi CA bundle by default. Corporate CA certs installed into /etc/ssl/certs/ are invisible to Python HTTP clients unless explicitly pointed to the system bundle.
Finding 2: Three separate HTTP stacks
ss-tools has three LLM HTTP clients (httpx for async, requests for sync, Playwright for screenshots), each with independent CA trust configuration.
Finding 3: .pem extension ignored by update-ca-certificates
On Debian Bookworm (python:3.11-slim), update-ca-certificates only processes .crt files.
Files with .pem extension in /usr/local/share/ca-certificates/ are silently skipped.
This left ca-certificates.crt empty (0 certs) while hash symlinks existed.
Fix: save downloaded certificates with .crt extension.
Finding 4: DER format from corporate PKI
Corporate PKI servers (pki.rusal.com) often serve certificates in DER (binary) format.
update-ca-certificates requires PEM. Auto-detection and conversion
(openssl x509 -inform DER -outform PEM) is essential.
Finding 5: Chicken-and-egg TLS bootstrap
Downloading a CA certificate from a PKI server that uses the same CA causes a TLS
verification loop. Entrypoint uses curl --insecure as fallback when initial curl
fails with TLS error.
Finding 6: NSS DB format
Chromium uses NSS Shared DB in ~/.pki/nssdb/. The sql: prefix is required for
certutil to use the SQLite format. Without it, certutil defaults to the legacy
DBM format which Chromium may not read.
Finding 7: OpenSSL 3.x cafile vs capath (CRITICAL)
OpenSSL 3.x treats non-self-signed certificates in -CAfile as trust anchors,
NOT as intermediates. This means intermediate CA certificates (Policy CA, RGM Issuing CA)
are ignored when using -CAfile /etc/ssl/certs/ca-certificates.crt, producing
verify code 20. Using -CApath /etc/ssl/certs/ (directory with hash symlinks)
correctly builds the full chain, producing verify code 0.
This affects ALL Python libraries:
ssl.create_default_context(cafile=...)→ calls OpenSSL'sSSL_CTX_load_verify_locationswith the file, which exhibits the same limitationssl.create_default_context(capath=...)→ works correctlyrequests.get(verify="/path/to/file")→ fails (uses cafile internally)requests.get(verify="/path/to/dir/")→ works (uses capath internally)
Finding 8: openssl s_client hangs without stdin
openssl s_client waits for input after TLS handshake. When run via
subprocess.run(cmd, capture_output=True), it hangs until timeout.
Fix: pass input="" or echo | openssl s_client ....
Deploy
# On target server:
xz -dc ss-tools-backend.0.1.7.tar.xz | docker load
xz -dc ss-tools-frontend.0.1.7.tar.xz | docker load
# Enable capath-based verification (remove LLM_SSL_VERIFY=false)
sed -i '/LLM_SSL_VERIFY=false/d' .env.enterprise-clean
docker compose -f docker-compose.enterprise-clean.yml \
--env-file .env.enterprise-clean down
docker compose -f docker-compose.enterprise-clean.yml \
--env-file .env.enterprise-clean up -d
# Verify
docker cp scripts/check_llm_certs.py ss-tools-backend-1:/tmp/
docker compose -f docker-compose.enterprise-clean.yml \
--env-file .env.enterprise-clean exec backend \
python3 /tmp/check_llm_certs.py --target https://lite.ai.rusal.com
Version History
| Version | Changes |
|---|---|
| 0.1.5 | Initial SSL support: LLM_SSL_VERIFY, _format_connection_error(), CA download via URL, NSS import |
| 0.1.6 | QA fixes: fingerprint dedup, hash symlink collision, NSS collision, DER→PEM error handling, chicken-and-egg TLS, nullglob, translate plugin SSL |
| 0.1.7 | capath instead of cafile: OpenSSL 3.x cafile limitation discovered and fixed. .crt extension for downloaded certs. Diagnostic script rewritten. |