fix(pm): cold updates on Python < 3.13 trust a stored intermediate CA
An install still on its legacy 3.11 venv runs its first `hermes update` from that interpreter, which downloads uv and Python 3.14 with plain urllib before PM's worker (and truststore) exists. Windows can cache Let's Encrypt "Root YR" in its intermediate store without ISRG Root X1; GitHub's asset CDN now chains through Root YR, so 3.11 rejected every download with "unable to get issuer certificate" while PowerShell and 3.14 accepted it. Python 3.13+ sets VERIFY_X509_PARTIAL_CHAIN by default. Set it on the PM downloader's opener so the legacy interpreter applies the same rule; hostname, expiry and pinned sha256 checks are unchanged. Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import logging
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -65,7 +66,18 @@ class _HttpsRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
_OPENER = urllib.request.build_opener(_HttpsRedirectHandler())
|
||||
def _https_context() -> ssl.SSLContext:
|
||||
# Trust a CA the store holds even when its own issuer is absent: Windows can
|
||||
# cache Let's Encrypt "Root YR" without ISRG Root X1. Python 3.13+ does this
|
||||
# by default; the older interpreter that starts a cold update does not.
|
||||
context = ssl.create_default_context()
|
||||
context.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
|
||||
return context
|
||||
|
||||
|
||||
_OPENER = urllib.request.build_opener(
|
||||
_HttpsRedirectHandler(), urllib.request.HTTPSHandler(context=_https_context())
|
||||
)
|
||||
|
||||
|
||||
class DownloadError(RuntimeError):
|
||||
|
||||
@@ -185,3 +185,96 @@ def test_importing_launch_does_not_patch_ssl_context():
|
||||
cwd=repo_root(), capture_output=True, text=True, timeout=30, check=False,
|
||||
)
|
||||
assert child.returncode == 0, child.stderr
|
||||
|
||||
|
||||
|
||||
def test_cold_downloader_trusts_a_stored_intermediate_without_its_root(tmp_path):
|
||||
"""Windows can cache an intermediate (Let's Encrypt "Root YR") without its
|
||||
root; Python < 3.13 then refused every GitHub asset before PM existed."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from ipaddress import ip_address
|
||||
import hashlib
|
||||
import ssl
|
||||
import threading
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
def issue(name, signer=None):
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
|
||||
signer_key, signer_name = signer or (key, subject)
|
||||
builder = (x509.CertificateBuilder().subject_name(subject).issuer_name(signer_name)
|
||||
.public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1)).not_valid_after(now + timedelta(days=1))
|
||||
.add_extension(x509.BasicConstraints(ca=name != "leaf", path_length=None), critical=True))
|
||||
if name == "leaf":
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName([x509.IPAddress(ip_address("127.0.0.1"))]), critical=False)
|
||||
return key, subject, builder.sign(signer_key, hashes.SHA256())
|
||||
|
||||
root_key, root_name, _ = issue("absent root")
|
||||
middle_key, middle_name, middle = issue("stored intermediate", (root_key, root_name))
|
||||
leaf_key, _, leaf = issue("leaf", (middle_key, middle_name))
|
||||
pem = lambda cert: cert.public_bytes(serialization.Encoding.PEM) # noqa: E731
|
||||
stores = {"stored": pem(middle), "unrelated": pem(issue("unrelated CA")[2])}
|
||||
for name, body in stores.items():
|
||||
(tmp_path / f"{name}.pem").write_bytes(body)
|
||||
(tmp_path / "chain.pem").write_bytes(pem(leaf) + pem(middle))
|
||||
(tmp_path / "leaf.key").write_bytes(leaf_key.private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
||||
payload = b"pinned tool"
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(tmp_path / "chain.pem", tmp_path / "leaf.key")
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
# The cold update parent is the legacy venv's 3.11/3.12: drop the default
|
||||
# verify flags 3.13 added, on any host Python.
|
||||
driver = """
|
||||
import ssl, sys
|
||||
from pathlib import Path
|
||||
legacy = ssl.create_default_context
|
||||
def create(*args, **kwargs):
|
||||
context = legacy(*args, **kwargs)
|
||||
context.verify_flags &= ~(ssl.VERIFY_X509_PARTIAL_CHAIN | ssl.VERIFY_X509_STRICT)
|
||||
return context
|
||||
ssl.create_default_context = ssl._create_default_https_context = create
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from pm.downloader import Download, Source
|
||||
dest = Path(sys.argv[3])
|
||||
Download([Source(sys.argv[2], dest, sys.argv[4])], partials_dir=dest.parent / "partials").run()
|
||||
"""
|
||||
(tmp_path / "no-cert-dir").mkdir()
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith(("PYTHON", "SSL_"))}
|
||||
env.update(SSL_CERT_DIR=str(tmp_path / "no-cert-dir"), NO_PROXY="127.0.0.1")
|
||||
results = {}
|
||||
try:
|
||||
for name in stores:
|
||||
dest = tmp_path / name / "tool"
|
||||
results[name] = subprocess.run(
|
||||
[sys.executable, "-I", "-S", "-c", driver, str(Path(__file__).resolve().parents[2]),
|
||||
f"https://127.0.0.1:{server.server_port}/tool", str(dest),
|
||||
hashlib.sha256(payload).hexdigest()],
|
||||
env={**env, "SSL_CERT_FILE": str(tmp_path / f"{name}.pem")},
|
||||
capture_output=True, text=True, timeout=60)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
assert results["stored"].returncode == 0, results["stored"].stderr
|
||||
assert (tmp_path / "stored" / "tool").read_bytes() == payload
|
||||
assert "CERTIFICATE_VERIFY_FAILED" in results["unrelated"].stderr
|
||||
assert not (tmp_path / "unrelated" / "tool").exists()
|
||||
|
||||
Reference in New Issue
Block a user