diff --git a/AGENTS.md b/AGENTS.md index 94a1c5d..e931d31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ scripts/deploy.sh [] # ship: CI green, then build a release ## Detail Docs - [docs/specs/2026-09-29-processor-service-design.md](docs/specs/2026-09-29-processor-service-design.md) — the spec: decisions, grants, runtime, failure table, versioning, cutover, testing, contract quick reference -- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) — prerequisites, the broker credential handoff, `/etc/processor/`, releases and `scripts/deploy.sh`, the drift check, liveness, containment, install and rollback, operate, co-core bumps, Tailscale DNS, Node.js +- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) — prerequisites, the broker credential handoff, `/etc/processor/`, releases and `scripts/deploy.sh`, the drift check, liveness, containment, install and rollback, operate, co-core bumps, TLS trust, Tailscale DNS, Node.js - [docs/plans/](docs/plans/) — implementation plans - [docs/SKILLS.md](docs/SKILLS.md) — vendored agent skills: inventory, overrides (none), the refresh hook, worktrees - [docs/SOCRATICODE.md](docs/SOCRATICODE.md) — semantic search: tools, the shared store, link stubs, the pin, the memory decision diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f24d763..2e63c6b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -462,6 +462,10 @@ co-core is pinned `==`, and Processor owns the pin (spec D5, §5; amended 2026-1 4. **Notice to Watcher** before deploy: the new `processor_version`, and the moved digests. A change to the contract needs coordination with Watcher first, not just notice. The contract is the `content.process` / `content.derived` models and `canonical_text` semantics. A change to essence resolution is notice, not coordination (spec §5, #57). 5. Merge, then `scripts/deploy.sh`. +## TLS trust + +The service's TLS trust is certifi's bundle in the release venv, not the system store: GCS (`google-auth` / `google-cloud-storage` over `requests`) and the drift check's GitHub calls verify against `certifi.where()`, and nothing sets `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE`. A CA distrust (exe.dev's system-store remediation doesn't reach the venv) is a certifi bump: raise `constraint-dependencies` in `pyproject.toml` and the floor in `tests/test_tls_trust.py`, `uv lock --upgrade-package certifi` (the diff moves certifi alone, never `uv lock --upgrade`), merge, then `scripts/deploy.sh`. The restart is the fix: a running process keeps the bundle it loaded. Check from the release venv: `/srv/processor/live/.venv/bin/python -I -c "import certifi; print(certifi.__version__)"`. certifi isn't co-core, so `processor_version` doesn't move and Watcher needs no notice. Since #66: ≥ 2026.10.19, without `emSign ECC Root CA - G3` (the eMudhra compromise, https://exe.dev/security/emudhra-compromise). + ## Tailscale DNS Since #8 (2026-10-01) this VM runs Tailscale with `--accept-dns=true`, as every cohort node does (observo#631, notifier#43 D8). It had run with DNS off since provisioning: the `tailscale up` of 2026-09-29, from an Observo session (observo#629), passed `--accept-dns=false`, and nothing records a reason. diff --git a/docs/plans/2026-10-23-66-certifi.md b/docs/plans/2026-10-23-66-certifi.md new file mode 100644 index 0000000..47b7c9e --- /dev/null +++ b/docs/plans/2026-10-23-66-certifi.md @@ -0,0 +1,32 @@ +--- +title: certifi ≥ 2026.10.19, so the venv stops trusting the emSign G3 chain (#66) +date: 2026-10-23 +status: per #66's hand-off from epic #24 (2026-10-23) +--- + +# certifi ≥ 2026.10.19 + +## Problem + +The services' TLS trust is certifi's bundle, not the system store. `uv.lock` has certifi 2026.07.22, which still holds `emSign ECC Root CA - G3`, the root of the eMudhra-compromised chains (exe.dev advisory). From the live venv, `requests.get` on the advisory's test host returns 200. GCS (`google-auth` / `google-cloud-storage` over `requests`) and the drift check's GitHub calls use it. Nothing overrides certifi (`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE`: none in `deploy/`, `src/`, or the unit). 2026.10.19 drops G3 and keeps emSign G1 and C3. + +## Approach + +- `uv lock --upgrade-package certifi`: the diff moves certifi alone. Never `uv lock --upgrade` (co-core's pin; `tests/test_pin.py`). +- A floor that survives a re-lock: `[tool.uv] constraint-dependencies = ["certifi>=2026.10.19"]`, commented with #66 and the advisory. certifi stays transitive, out of `[project] dependencies`. +- New `tests/test_tls_trust.py` (not `test_pin.py`: that is co-core's pin and its contract): + - the lock resolves certifi ≥ 2026.10.19, and so does the installed package, compared as dated parts (`2026.7.22` in the lock, `2026.07.22` installed); + - the installed bundle, parsed as certificates (`ssl` `get_ca_certs()`, not the comment text), has no subject `emSign ECC Root CA - G3`. Positive control: it does have `emSign Root CA - G1`, so the matcher finds the family it looks for. +- DEPLOYMENT.md, a `## TLS trust` note: the trust is certifi's bundle from the release venv; a CA distrust is a certifi bump, a lock change and a deploy; exe.dev's system-store remediation doesn't reach it; the restart is the fix. + +## Test + +Red first on today's lock (2026.07.22 and G3 present), green after the lock and `uv sync`. + +## Ship + +PR (`Refs #66`) → FF merge → `git pull --ff-only` → `scripts/deploy.sh` (`uv.lock` and `pyproject.toml` are deploy paths). Live checks, from `/srv/processor/live/.venv` as `exedev`: certifi ≥ 2026.10.19; the noct host raises `SSLError`; `https://storage.googleapis.com/` answers; `starting` has the new build, `processor_version` `0.38.0+1`, `child_containment: required`, `NRestarts=0`; the next command acks; the next drift run is `ok`/202. `processor_version` doesn't move, so no Watcher notice (D5). + +## Open questions + +None. diff --git a/pyproject.toml b/pyproject.toml index 151d4ae..e792340 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,3 +82,8 @@ packages = ["src/processor"] # cannobserv release tag by scripts/build_wheelhouse.sh. find-links locks by # filename, not hash, so either source satisfies the same uv.lock. find-links = ["./.wheelhouse"] +# certifi is transitive (requests, httpx), and its bundle is the services' TLS trust, +# not the system store. 2026.10.19 drops emSign ECC Root CA - G3, the root of the +# eMudhra-compromised chains (https://exe.dev/security/emudhra-compromise, #66); the +# floor keeps a re-lock from resolving an older one. tests/test_tls_trust.py checks it. +constraint-dependencies = ["certifi>=2026.10.19"] diff --git a/tests/test_tls_trust.py b/tests/test_tls_trust.py new file mode 100644 index 0000000..edf8d60 --- /dev/null +++ b/tests/test_tls_trust.py @@ -0,0 +1,51 @@ +"""The services' TLS trust is certifi's bundle, and it must not trust emSign ECC G3 (#66). + +GCS (``google-auth`` / ``google-cloud-storage`` over ``requests``) and the drift check's +GitHub calls verify against ``certifi.where()``, not the system store, so exe.dev's +system-store remediation of the eMudhra compromise doesn't reach them +(https://exe.dev/security/emudhra-compromise). certifi 2026.10.19 drops +``emSign ECC Root CA - G3``, the compromised chains' root. A later lock must not bring +it back: ``[tool.uv] constraint-dependencies`` holds the floor, and these tests check it. +""" + +import ssl +import tomllib +from pathlib import Path + +import certifi + +ROOT = Path(__file__).resolve().parent.parent +FLOOR = (2026, 10, 19) +DISTRUSTED = "emSign ECC Root CA - G3" + + +def _dated(version: str) -> tuple[int, ...]: + # The lock normalises 2026.07.22 to 2026.7.22; compare parts, not strings. + return tuple(int(part) for part in version.split(".")) + + +def _bundle_common_names() -> set[str]: + # The certificates themselves, not cacert.pem's comment lines. + certs = ssl.create_default_context(cafile=certifi.where()).get_ca_certs() + return {dict(rdn[0] for rdn in c["subject"]).get("commonName", "") for c in certs} + + +def test_dated_compares_parts() -> None: + assert _dated("2026.7.22") == _dated("2026.07.22") < FLOOR <= _dated("2026.10.19") + + +def test_lock_resolves_certifi_at_the_floor() -> None: + lock = tomllib.loads((ROOT / "uv.lock").read_text()) + (locked,) = [p["version"] for p in lock["package"] if p["name"] == "certifi"] + assert _dated(locked) >= FLOOR, locked + + +def test_installed_certifi_is_at_the_floor() -> None: + assert _dated(certifi.__version__) >= FLOOR, certifi.__version__ + + +def test_the_bundle_does_not_trust_emsign_ecc_g3() -> None: + names = _bundle_common_names() + # Control: the matcher sees eMudhra's roots, and the one 2026.10.19 keeps. + assert "emSign Root CA - G1" in names + assert DISTRUSTED not in names diff --git a/uv.lock b/uv.lock index 4743697..64f6ea7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,14 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version < '3.14'", ] +[manifest] +constraints = [{ name = "certifi", specifier = ">=2026.10.19" }] + [[package]] name = "annotated-types" version = "0.8.0" @@ -43,11 +46,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, ] [[package]]