From 6253ab2925347175a9a864815ab16a9223c36b17 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Mon, 14 Sep 2026 21:45:18 +0200 Subject: [PATCH] Give the frozen exporter a CA bundle when the system offers none Sign-in succeeded and then the very next request, the mobileme login at setup.icloud.com, died with CERTIFICATE_VERIFY_FAILED on a minimal Linux desktop (#206). gsa.apple.com chains to the Apple root FindMy.py pins and carries, so it verifies with no trust store; setup.icloud.com presents an ordinary public certificate that needs the platform's CA bundle, and a PyInstaller build has none - its Python looks for one at the build machine's paths, absent on the user's. exporter.certs.ensure_ca_bundle points OpenSSL at certifi's bundled certificates through SSL_CERT_FILE, which is what the reporter set by hand as the workaround, and which ssl.create_default_context reads when FindMy.py builds its context. Both entry points call it before the first request. It only fills a gap: a user's own SSL_CERT_FILE and a machine whose default bundle exists are both left untouched, so from-source runs are unaffected. Closes #206. Co-Authored-By: Claude Opus 4.8 --- python/exporter/certs.py | 70 ++++++++++++++++++++++++++++++ python/exporter/cli.py | 5 +++ python/exporter/wizard.py | 6 +++ python/test/test_certs.py | 91 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 python/exporter/certs.py create mode 100644 python/test/test_certs.py diff --git a/python/exporter/certs.py b/python/exporter/certs.py new file mode 100644 index 00000000..a2206c35 --- /dev/null +++ b/python/exporter/certs.py @@ -0,0 +1,70 @@ +""" +Make a frozen build able to verify Apple's ordinary public certificates. + +Every request this exporter makes to Apple is TLS-verified by FindMy.py, against the platform's +trust store plus Apple's own pinned 2006 root. Two kinds of Apple host sit behind that, and they +fail apart: + +- ``gsa.apple.com`` (sign-in) chains to the pinned root FindMy.py carries, so it verifies with no + trust store at all. +- ``setup.icloud.com`` (the mobileme login, reached right after the verification code) presents an + ordinary public certificate, which needs the platform's CA bundle like any other website. + +A PyInstaller build carries no trust store, and its Python looks for one at the paths OpenSSL was +compiled with on the *build* machine, which do not exist on the user's. So sign-in succeeded and +then the very next request died with ``CERTIFICATE_VERIFY_FAILED: unable to get local issuer +certificate`` -- reported in +`#206 `_ on a minimal Linux desktop, +where ``export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`` was the confirmed workaround. + +:func:`ensure_ca_bundle` is that workaround, done for the user and portably. ``certifi`` ships +Mozilla's CA bundle *inside* the binary, and pointing OpenSSL's default search at it -- through the +same ``SSL_CERT_FILE`` variable OpenSSL reads when a context loads its default certificates -- fixes +every public Apple host at once. FindMy.py needs to know nothing: its +``ssl.create_default_context()`` reads that variable when it builds the context, which is why this +has to run before the first request, and why the entry points call it first thing. + +It only fills a gap. A user who has set ``SSL_CERT_FILE`` themselves keeps it, and a machine whose +own default bundle exists -- every normal from-source run -- is left alone, so this changes nothing +outside the frozen-on-a-bare-system case it is for. +""" + +from __future__ import annotations + +import logging +import os +import ssl +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def ensure_ca_bundle() -> None: + """Point OpenSSL at ``certifi``'s bundle when the platform offers no usable one.""" + if os.environ.get("SSL_CERT_FILE"): + # The user, or a launcher, has already chosen a bundle. Theirs wins: it may point at a + # corporate store this one would not contain. + return + + default = ssl.get_default_verify_paths().cafile + if default and Path(default).is_file(): + # OpenSSL's own default file is present, so the platform has a working trust store and + # there is nothing to fix. This is every from-source run, and the case this must not + # disturb -- a machine's store can carry CAs certifi's does not. + return + + try: + import certifi + except ImportError: + # Nothing to fall back to. Leave verification to fail loudly rather than papering over a + # build that shipped without certifi. + logger.warning("No usable system CA bundle and certifi is not installed; TLS may fail.") + return + + bundle = certifi.where() + if not bundle or not Path(bundle).is_file(): + logger.warning("certifi reported a CA bundle at %r, which is not a file.", bundle) + return + + os.environ["SSL_CERT_FILE"] = bundle + logger.info("No system CA bundle found; using certifi's at %s", bundle) diff --git a/python/exporter/cli.py b/python/exporter/cli.py index aef66bde..666354bf 100644 --- a/python/exporter/cli.py +++ b/python/exporter/cli.py @@ -49,6 +49,7 @@ suggested_name, ) from exporter.icloud import Candidate, ExportSourceError +from exporter.certs import ensure_ca_bundle from exporter.version import EXPORT_VIA_CLI, GITHUB_ISSUES_LINK, VERSION, describe_build from opentagviewer_export import ( ExportError, @@ -921,6 +922,10 @@ def main(argv: Sequence[str] | None = None) -> int: configure_logging(arguments.verbose) + # Before any request: a frozen build on a bare system has no trust store, and the first + # thing that needs one is the mobileme login right after the code. See exporter.certs. + ensure_ca_bundle() + if arguments.non_interactive: prompts.forbid_prompting() diff --git a/python/exporter/wizard.py b/python/exporter/wizard.py index f8164ff9..73a92f8a 100644 --- a/python/exporter/wizard.py +++ b/python/exporter/wizard.py @@ -34,6 +34,7 @@ from exporter import icloud, localsource, source, terms from exporter.asyncui import Asker, Cancelled, run_with_progress +from exporter.certs import ensure_ca_bundle from exporter.codes import ( VERIFICATION_CODE_LENGTH, is_verification_code, @@ -1612,4 +1613,9 @@ def _emu_run(state: dict) -> None: configure_logging() logger.info("Starting %s", APP_TITLE) + # Before any request: a frozen build on a bare system has no trust store, and sign-in + # succeeds against Apple's pinned root only to fail at the next public host. See + # exporter.certs and issue #206. + ensure_ca_bundle() + WizardApp().mainloop() diff --git a/python/test/test_certs.py b/python/test/test_certs.py new file mode 100644 index 00000000..f9443831 --- /dev/null +++ b/python/test/test_certs.py @@ -0,0 +1,91 @@ +""" +Tests for `exporter.certs`, which fills the CA gap a frozen build has on a bare system. + +The behaviour that matters is when it acts and when it keeps its hands off: a user's own +`SSL_CERT_FILE` and a machine with a working store must both be left exactly as they were, or a +fix for #206 becomes a regression for everyone the bug never touched. +""" + +from __future__ import annotations + +import ssl +from pathlib import Path + +import pytest + +from exporter import certs + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + +def _no_default(monkeypatch): + """Pretend OpenSSL's compiled default file does not exist -- the frozen-build case.""" + paths = ssl.DefaultVerifyPaths("/nonexistent/cert.pem", "", "", "/nonexistent", "", "") + monkeypatch.setattr(ssl, "get_default_verify_paths", lambda: paths) + + +def _has_default(monkeypatch, tmp_path): + """A present default file -- a normal from-source run.""" + real = tmp_path / "ca-certificates.crt" + real.write_text("-----BEGIN CERTIFICATE-----\n") + paths = ssl.DefaultVerifyPaths(str(real), str(real), "", "/etc/ssl/certs", "", "") + monkeypatch.setattr(ssl, "get_default_verify_paths", lambda: paths) + + +def test_a_users_own_setting_is_left_untouched(monkeypatch): + _no_default(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", "/home/someone/corporate-ca.pem") + + certs.ensure_ca_bundle() + + import os + + assert os.environ["SSL_CERT_FILE"] == "/home/someone/corporate-ca.pem" + + +def test_a_working_system_store_is_left_alone(monkeypatch, tmp_path): + _has_default(monkeypatch, tmp_path) + + certs.ensure_ca_bundle() + + import os + + assert "SSL_CERT_FILE" not in os.environ, "must not override a machine that already verifies" + + +def test_certifi_fills_the_gap_when_nothing_else_will(monkeypatch): + _no_default(monkeypatch) + + certs.ensure_ca_bundle() + + import os + + bundle = os.environ.get("SSL_CERT_FILE") + assert bundle is not None, "a bare frozen build must be given a bundle" + assert Path(bundle).is_file() + import certifi + + assert bundle == certifi.where() + + +def test_nothing_is_set_when_certifi_is_absent(monkeypatch): + _no_default(monkeypatch) + import builtins + + real_import = builtins.__import__ + + def deny_certifi(name, *args, **kwargs): + if name == "certifi": + raise ImportError("no certifi") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", deny_certifi) + + certs.ensure_ca_bundle() + + import os + + assert "SSL_CERT_FILE" not in os.environ