Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions python/exporter/certs.py
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/parawanderer/OpenTagViewer/issues/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)
5 changes: 5 additions & 0 deletions python/exporter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions python/exporter/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
91 changes: 91 additions & 0 deletions python/test/test_certs.py
Original file line number Diff line number Diff line change
@@ -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
Loading