fix(egress-proxy): mint CA via openssl req so leaf AKI matches CA SKI
Root cause of the persistent SSL handshake failure: pipelock's `tls init` stamps a non-standard `Subject Key Identifier` on the CAs it generates (random rather than SHA-1 of the pubkey). mitmproxy computes the `Authority Key Identifier` on each leaf cert it mints as SHA-1(issuer's pubkey). openssl's chain validator uses the leaf's AKI to find the issuer cert by SKI; with pipelock's SKI off by definition, the lookup fails and openssl returns "unable to get local issuer certificate" — even though the CA is right there in the trust store with the matching SHA-256 fingerprint. (Also, pipelock generates EC CAs; the cert+key concat fit in 834 bytes vs ~2.3KB for RSA, which was the first red flag.) Diagnostic from a live bottle confirmed: leaf cert AKI: A8:F0:D5:E3:B5:B9:C2:38:2B:9F:DD:4A:DF:26:8C:72:19:A2:5E:94 CA cert SKI: 81:CA:6D:4C:ED:5C:C2:B1:48:0C:3E:E8:8D:73:86:97:B9:89:B4:3D CA cert + leaf cert: same Pipelock-named subject, same public key bytes openssl verify -CAfile <our CA> <leaf>: error 20 Fix: switch `egress_proxy_tls_init` from `pipelock tls init` to host `openssl req` with an explicit `subjectKeyIdentifier=hash` extension. SHA-1(pubkey) for the SKI matches what mitmproxy puts in the AKI, so chain validation works. The generated CA is also RSA-2048 / sha256WithRSAEncryption — mitmproxy's most-tested configuration. The new generator is independent of pipelock entirely (no docker run on the pipelock image to mint the CA), so the egress-proxy CA generation now requires only `openssl` on the host. macOS + Linux dev images both have it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -75,9 +75,7 @@ def build_egress_proxy_image() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
||||||
"""Mint the per-bottle egress-proxy MITM CA. Reuses the pipelock
|
"""Mint the per-bottle egress-proxy MITM CA via host `openssl req`.
|
||||||
binary's `tls init` subcommand — a known-good RSA CA minter we
|
|
||||||
already pin and run on this host.
|
|
||||||
|
|
||||||
Returns `(mitmproxy_pem, cert_only_pem)`:
|
Returns `(mitmproxy_pem, cert_only_pem)`:
|
||||||
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
- `mitmproxy_pem` is the single-PEM concat (cert + key)
|
||||||
@@ -86,47 +84,75 @@ def egress_proxy_tls_init(stage_dir: Path) -> tuple[Path, Path]:
|
|||||||
trust store by `provision_ca` so the agent trusts the bumped
|
trust store by `provision_ca` so the agent trusts the bumped
|
||||||
CONNECT cert egress-proxy presents.
|
CONNECT cert egress-proxy presents.
|
||||||
|
|
||||||
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 600).
|
Why openssl req (not the pipelock binary's `tls init`):
|
||||||
Private keys never leave the host stage dir until
|
pipelock's CA generator stamps a non-standard `Subject Key
|
||||||
`DockerEgressProxy.start` docker-cps the concat file into the
|
Identifier` on the CA (random rather than SHA-1 of the pubkey).
|
||||||
sidecar; start.py's outer finally `shutil.rmtree`s the stage dir
|
mitmproxy computes the `Authority Key Identifier` on each leaf
|
||||||
after teardown.
|
it mints as SHA-1(issuer's pubkey). openssl's chain validator
|
||||||
|
uses the leaf's AKI to find the issuer cert by SKI; pipelock's
|
||||||
|
SKI doesn't match → openssl reports "unable to get local issuer
|
||||||
|
certificate" even though the CA is right there in the trust
|
||||||
|
store. openssl req's `subjectKeyIdentifier=hash` extension uses
|
||||||
|
SHA-1(pubkey), matching mitmproxy's computation.
|
||||||
|
|
||||||
Imported lazily inside the function so test patchers in
|
Both files live under `<stage_dir>/egress-proxy-ca/` (mode 644 —
|
||||||
pipelock-land don't need to know about us."""
|
`docker cp` preserves the mode into the container, where the
|
||||||
# Local import keeps the module-import graph free of a hard
|
mitmproxy user (uid 1000) reads them; the host stage_dir is
|
||||||
# pipelock-image dependency at top of file (we don't actually
|
mode 700 so the private key isn't world-exposed)."""
|
||||||
# need pipelock's *runtime* here, just its tls-init subcommand).
|
|
||||||
from .pipelock import PIPELOCK_IMAGE
|
|
||||||
work = stage_dir / "egress-proxy-ca"
|
work = stage_dir / "egress-proxy-ca"
|
||||||
work.mkdir(exist_ok=True)
|
work.mkdir(exist_ok=True)
|
||||||
result = subprocess.run(
|
key_path = work / "ca-key.pem"
|
||||||
["docker", "run", "--rm",
|
cert_path = work / "ca.pem"
|
||||||
"-v", f"{work}:/h",
|
cnf_path = work / "ca.cnf"
|
||||||
"-e", "PIPELOCK_HOME=/h",
|
|
||||||
PIPELOCK_IMAGE, "tls", "init"],
|
# RSA-2048 — broad mitmproxy compatibility (its default leaf-cert
|
||||||
capture_output=True,
|
# config matches RSA CAs without surprise), and openssl req's
|
||||||
text=True,
|
# default behavior here is exactly what we want.
|
||||||
check=False,
|
keygen = subprocess.run(
|
||||||
|
["openssl", "genrsa", "-out", str(key_path), "2048"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if keygen.returncode != 0:
|
||||||
die(f"egress-proxy tls init failed: {result.stderr.strip()}")
|
die(f"egress-proxy ca keygen failed: {keygen.stderr.strip()}")
|
||||||
cert = work / "ca.pem"
|
|
||||||
key = work / "ca-key.pem"
|
# `subjectKeyIdentifier=hash` makes openssl compute the SKI as
|
||||||
if not cert.is_file() or not key.is_file():
|
# SHA-1(pubkey), matching how mitmproxy computes the AKI on the
|
||||||
die(f"egress-proxy tls init did not produce ca files in {work}")
|
# leaves it later mints. Without this, chain validation breaks
|
||||||
# Mode 644 (not 600) so `docker cp` preserves world-readability
|
# despite the CA being present in the trust store.
|
||||||
# inside the container — the mitmproxy user (uid 1000) needs to
|
cnf_path.write_text(
|
||||||
# read the file, and the host uid `docker cp` propagates from the
|
"[req]\n"
|
||||||
# source doesn't match. The host stage_dir is mode 700 so other
|
"distinguished_name = req_dn\n"
|
||||||
# host users still can't traverse in; the private key isn't
|
"prompt = no\n"
|
||||||
# exposed despite the file mode.
|
"x509_extensions = v3_ca\n"
|
||||||
cert.chmod(0o644)
|
"\n"
|
||||||
|
"[req_dn]\n"
|
||||||
|
"O = claude-bottle\n"
|
||||||
|
"CN = claude-bottle egress-proxy CA\n"
|
||||||
|
"\n"
|
||||||
|
"[v3_ca]\n"
|
||||||
|
"basicConstraints = critical, CA:TRUE\n"
|
||||||
|
"keyUsage = critical, keyCertSign, cRLSign\n"
|
||||||
|
"subjectKeyIdentifier = hash\n"
|
||||||
|
)
|
||||||
|
cnf_path.chmod(0o644)
|
||||||
|
|
||||||
|
req = subprocess.run(
|
||||||
|
["openssl", "req", "-x509", "-new", "-nodes",
|
||||||
|
"-key", str(key_path),
|
||||||
|
"-sha256", "-days", "365",
|
||||||
|
"-config", str(cnf_path),
|
||||||
|
"-out", str(cert_path)],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if req.returncode != 0:
|
||||||
|
die(f"egress-proxy ca cert generation failed: {req.stderr.strip()}")
|
||||||
|
|
||||||
|
cert_path.chmod(0o644)
|
||||||
# mitmproxy reads cert + key from a single concatenated PEM file.
|
# mitmproxy reads cert + key from a single concatenated PEM file.
|
||||||
mitm = work / "mitmproxy-ca.pem"
|
mitm = work / "mitmproxy-ca.pem"
|
||||||
mitm.write_bytes(cert.read_bytes() + key.read_bytes())
|
mitm.write_bytes(cert_path.read_bytes() + key_path.read_bytes())
|
||||||
mitm.chmod(0o644)
|
mitm.chmod(0o644)
|
||||||
return (mitm, cert)
|
return (mitm, cert_path)
|
||||||
|
|
||||||
|
|
||||||
class DockerEgressProxy(EgressProxy):
|
class DockerEgressProxy(EgressProxy):
|
||||||
|
|||||||
Reference in New Issue
Block a user