diff --git a/.github/workflows/os-trust-drill.yml b/.github/workflows/os-trust-drill.yml new file mode 100644 index 00000000..727d1dfc --- /dev/null +++ b/.github/workflows/os-trust-drill.yml @@ -0,0 +1,60 @@ +name: os-trust-drill + +# ENG-407: on Windows ThinkPads sign-in 502'd with CERTIFICATE_VERIFY_FAILED because the Python backend trusted +# certifi's file while the browser trusted the machine's own root store. The fix routes the backend through the +# OS store. That is only provable on a real OS with a real root installed, so this job mints a throwaway root, +# installs it the way an endpoint tool would, and proves both directions plus the real sign-in endpoint. + +on: + push: + paths: + - 'backend/config/os_trust.py' + - 'backend/main.py' + - 'backend/apps/auth/router.py' + - 'backend/requirements.lock' + - 'scripts/os_trust_drill.py' + - '.github/workflows/os-trust-drill.yml' + workflow_dispatch: + +jobs: + drill: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: backend/requirements.lock + + - name: Install the backend's locked dependencies (the same set the packaged python-env ships) + run: pip install -r backend/requirements.lock + + - name: Mint a throwaway root and a leaf signed under it + run: python scripts/os_trust_drill.py mint drill-ca + + - name: Install the root into the OS store the way an endpoint tool would (Windows) + if: runner.os == 'Windows' + run: certutil -addstore -f Root drill-ca/root.pem + + - name: Install the root into the OS store the way an endpoint tool would (macOS) + if: runner.os == 'macOS' + run: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain drill-ca/root.pem + + - name: Stock certifi refuses the OS root, the armed backend accepts it, the cloud verifies + shell: bash + run: python scripts/os_trust_drill.py verify drill-ca + + - name: The real sign-in endpoint, all three directions + shell: bash + run: | + set -e + python scripts/os_trust_drill.py signin armed + python scripts/os_trust_drill.py signin armed-foreign-ca + python scripts/os_trust_drill.py signin stock-foreign-ca diff --git a/backend/config/os_trust.py b/backend/config/os_trust.py new file mode 100644 index 00000000..9a86560c --- /dev/null +++ b/backend/config/os_trust.py @@ -0,0 +1,45 @@ +"""Route every outbound TLS verification in this process through the OS trust store. + +httpx, and everything built on it, verifies against certifi's bundled CA file, which never sees a +root that an endpoint security tool or a corporate proxy installed into the Windows or macOS store. +Chromium and the updater honor those roots, so the app looks healthy while the Python backend refuses +every https call it makes: sign-in, the cloud, telemetry. Arming the OS store here, before any client +exists, makes the backend trust exactly what the user's browser trusts and nothing more; an untrusted, +self-signed or expired certificate still fails closed. +""" + +import logging +import platform +import ssl +from typing import Literal + +from typeguard import typechecked + +logger = logging.getLogger(__name__) + +TrustSource = Literal["os-store", "certifi"] + +# Linux keeps certifi: truststore there only reads OpenSSL's default paths, which a bare container may not have. +P_OS_STORE_PLATFORMS = frozenset({"Darwin", "Windows"}) + + +@typechecked +def install_os_trust() -> TrustSource: + system = platform.system() + if system not in P_OS_STORE_PLATFORMS: + logger.info("tls trust: certifi bundle (%s has no OS store hook)", system) + return "certifi" + try: + import truststore + truststore.inject_into_ssl() + # The factory httpx calls must hand back the OS-store class, or "armed" would be a lie. + if type(ssl.create_default_context()) is not truststore.SSLContext: + raise RuntimeError("ssl.create_default_context() still builds the stock SSLContext") + except Exception as e: + logger.warning( + "tls trust: OS store unavailable on %s (%s); https from this process trusts the certifi bundle only, so a proxy or security-tool root will be refused", + system, e, + ) + return "certifi" + logger.info("tls trust: OS store on %s", system) + return "os-store" diff --git a/backend/main.py b/backend/main.py index 2dcfdc8b..7c8fd543 100644 --- a/backend/main.py +++ b/backend/main.py @@ -15,6 +15,11 @@ if not p_backend_logger.handlers: logger = logging.getLogger(__name__) +from backend.config.os_trust import install_os_trust + +# Before any app module can build an httpx client, or the stock certifi-only context would be baked in. +install_os_trust() + from fastapi.responses import JSONResponse, HTMLResponse from fastapi import Request diff --git a/backend/requirements.lock b/backend/requirements.lock index a57e9900..fa10c3da 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -278,7 +278,7 @@ click==8.4.1 \ # via # rich-toolkit # uvicorn -colorama==0.4.6 ; sys_platform == 'win32' or platform_system == 'Windows' \ +colorama==0.4.6 ; sys_platform == 'win32' \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 # via @@ -1514,6 +1514,10 @@ trafilatura==2.0.0 \ --hash=sha256:77eb5d1e993747f6f20938e1de2d840020719735690c840b9a1024803a4cd51d \ --hash=sha256:ceb7094a6ecc97e72fea73c7dba36714c5c5b577b6470e4520dca893706d6247 # via -r backend/requirements.txt +truststore==0.10.4 \ + --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 + # via -r backend/requirements.txt typeguard==4.4.2 \ --hash=sha256:77a78f11f09777aeae7fa08585f33b5f4ef0e7335af40005b0c422ed398ff48c \ --hash=sha256:a6f1065813e32ef365bc3b3f503af8a96f9dd4e0033a02c28c4a4983de8c6c49 @@ -1543,7 +1547,7 @@ typing-inspection==0.4.2 \ # mcp # pydantic # pydantic-settings -tzdata==2026.2 ; platform_system == 'Windows' \ +tzdata==2026.2 ; sys_platform == 'win32' \ --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \ --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7 # via tzlocal diff --git a/backend/requirements.txt b/backend/requirements.txt index 358f1214..3c1c0d77 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,8 @@ typeguard==4.4.2 python-dotenv==1.1.1 Pillow==12.2.0 httpx==0.28.1 +# truststore: verify https against the OS trust store (Windows/macOS) instead of certifi alone, so a root an endpoint tool or corporate proxy installed stops refusing sign-in and every other backend call; the browser already trusts those roots. ctypes only, 68KB, MIT. +truststore==0.10.4 trafilatura==2.0.0 # curl_cffi: Chrome TLS-fingerprint impersonation for the keyless search rungs. Measured 8/8 against DuckDuckGo where plain httpx scored 4/8 on the same queries; abi3 wheels for macos arm64/x64 and win_amd64. Guarded import, so a missing wheel degrades to httpx instead of breaking the backend. curl_cffi==0.15.0 diff --git a/backend/tests/test_os_trust.py b/backend/tests/test_os_trust.py new file mode 100644 index 00000000..70a6ebe7 --- /dev/null +++ b/backend/tests/test_os_trust.py @@ -0,0 +1,128 @@ +"""The backend's https trust follows the OS store on desktop platforms, and still fails closed. + +Sign-in on Windows ThinkPads died with `unable to get local issuer certificate`: an endpoint tool's +root sat in the Windows store, the browser trusted it, and certifi (the only store httpx read) had +never heard of it. These pin that the OS store is armed before any client exists, that a failure to +arm is said out loud, and that a certificate nobody trusts is still refused with the OS store on. +""" + +import datetime +import http.server +import logging +import platform +import ssl +import threading +from pathlib import Path + +import httpx +import pytest +import truststore + +from backend.config.os_trust import install_os_trust + +P_REPO = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def stock_ssl_afterwards(): + yield + truststore.extract_from_ssl() + + +def test_desktop_platforms_arm_the_os_store_in_the_factory_httpx_calls(monkeypatch, stock_ssl_afterwards): + monkeypatch.setattr(platform, "system", lambda: "Windows") + assert install_os_trust() == "os-store" + # httpx builds its verify context with ssl.create_default_context(); that is the call that must change. + assert type(ssl.create_default_context()) is truststore.SSLContext + + +def test_off_desktop_certifi_stays_and_says_so(monkeypatch, caplog): + monkeypatch.setattr(platform, "system", lambda: "Linux") + before = ssl.SSLContext + with caplog.at_level(logging.INFO, logger="backend.config.os_trust"): + assert install_os_trust() == "certifi" + assert ssl.SSLContext is before + assert any("certifi bundle" in r.getMessage() for r in caplog.records) + + +def test_a_store_that_cannot_arm_is_reported_not_swallowed(monkeypatch, caplog): + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + def p_boom() -> None: + raise OSError("Security.framework missing") + + monkeypatch.setattr(truststore, "inject_into_ssl", p_boom) + with caplog.at_level(logging.WARNING, logger="backend.config.os_trust"): + assert install_os_trust() == "certifi" + warned = [r for r in caplog.records if r.levelno == logging.WARNING] + assert warned and "certifi bundle only" in warned[0].getMessage() + + +def p_self_signed_pem(tmp_path: Path) -> Path: + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName([x509.IPAddress(__import__("ipaddress").ip_address("127.0.0.1"))]), critical=False) + .sign(key, hashes.SHA256()) + ) + pem = tmp_path / "nobody-trusts-me.pem" + pem.write_bytes( + key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + + cert.public_bytes(serialization.Encoding.PEM) + ) + return pem + + +class P_QuietHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.end_headers() + self.wfile.write(b"hello") + + def log_message(self, *args) -> None: + pass + + +class P_QuietServer(http.server.ThreadingHTTPServer): + def handle_error(self, request, client_address) -> None: + pass + + +@pytest.mark.skipif(platform.system() not in ("Darwin", "Windows"), reason="the OS store hook only arms on desktop platforms") +def test_the_os_store_still_refuses_a_certificate_nobody_trusts(tmp_path, stock_ssl_afterwards): + pem = p_self_signed_pem(tmp_path) + server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_ctx.load_cert_chain(pem) + server = P_QuietServer(("127.0.0.1", 0), P_QuietHandler) + server.socket = server_ctx.wrap_socket(server.socket, server_side=True) + threading.Thread(target=server.serve_forever, daemon=True).start() + url = f"https://127.0.0.1:{server.server_address[1]}/" + try: + assert install_os_trust() == "os-store" + with pytest.raises(httpx.ConnectError): + httpx.get(url, timeout=5) + # Control: the server is really up, so the refusal above was verification and not a dead port. + assert httpx.get(url, timeout=5, verify=False).text == "hello" + finally: + server.shutdown() + + +def test_main_arms_os_trust_before_the_first_app_import(): + """A client built at import time would keep the stock context forever, so the arming has to come + before any backend.apps module loads. Index order in the source is the assertion.""" + src = (P_REPO / "backend" / "main.py").read_text() + assert "install_os_trust()" in src, "backend/main.py never arms the OS trust store" + assert src.index("install_os_trust()") < src.index("from backend.apps"), "OS trust is armed after an app import" diff --git a/scripts/os_trust_drill.py b/scripts/os_trust_drill.py new file mode 100644 index 00000000..4352873c --- /dev/null +++ b/scripts/os_trust_drill.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Closed-loop drill for ENG-407: does this machine's Python trust what its OS trusts, and does sign-in reach the cloud? + + mint