diff --git a/findmy/reports/anisette.py b/findmy/reports/anisette.py index 1c5e3e5..c6a78b1 100644 --- a/findmy/reports/anisette.py +++ b/findmy/reports/anisette.py @@ -15,18 +15,27 @@ from typing import BinaryIO, Literal, TypedDict from anisette import Anisette, AnisetteHeaders -from typing_extensions import override +from typing_extensions import Required, override from findmy import util logger = logging.getLogger(__name__) -class RemoteAnisetteMapping(TypedDict): +class RemoteAnisetteMapping(TypedDict, total=False): """JSON mapping representing state of a remote Anisette provider.""" - type: Literal["aniRemote"] - url: str + type: Required[Literal["aniRemote"]] + url: Required[str] + + allow_unverified_https: bool + """ + Only written when it is true, so existing files stay valid and unchanged. + + It has to be written at all because a provider is reconstructed from this: a user whose + own server has a self-signed certificate would otherwise find that saving and reloading + an account silently turned their working setup into a failing one. + """ class LocalAnisetteMapping(TypedDict): @@ -193,13 +202,29 @@ class RemoteAnisetteProvider(BaseAnisetteProvider, util.abc.Serializable[RemoteA _ANISETTE_DATA_VALID_FOR = 30 - def __init__(self, server_url: str) -> None: - """Initialize the provider with URL to te remote server.""" + def __init__(self, server_url: str, *, allow_unverified_https: bool = False) -> None: + """ + Initialize the provider with URL to te remote server. + + :param server_url: Where to fetch Anisette headers from. + :param allow_unverified_https: Skip certificate verification for **this server + only**. Off by default, and the only switch of its kind in the library. + + It exists for one case: an Anisette server you run yourself, over HTTPS, with a + self-signed certificate. A server reached over plain `http://` needs nothing -- + there is no TLS to verify -- and a public server with a real certificate needs + nothing either. Everything Apple-facing is verified regardless of this flag; it + reaches no request but the one to this URL. + + Turning it on means anything on the network path to that server can read and + alter the Anisette data your logins are built from. + """ super().__init__() self._server_url = server_url + self._allow_unverified_https = allow_unverified_https - self._http = util.http.HttpSession() + self._http = util.http.HttpSession(verify_tls=not allow_unverified_https) self._anisette_data: dict[str, str] | None = None self._anisette_data_expires_at: float = 0 @@ -208,13 +233,14 @@ def __init__(self, server_url: str) -> None: @override def to_json(self, dst: str | Path | io.TextIOBase | None = None, /) -> RemoteAnisetteMapping: """See :meth:`BaseAnisetteProvider.serialize`.""" - return util.files.save_and_return_json( - { - "type": "aniRemote", - "url": self._server_url, - }, - dst, - ) + state: RemoteAnisetteMapping = { + "type": "aniRemote", + "url": self._server_url, + } + if self._allow_unverified_https: + state["allow_unverified_https"] = True + + return util.files.save_and_return_json(state, dst) @classmethod @override @@ -228,7 +254,10 @@ def from_json( server_url = val["url"] - return cls(server_url) + return cls( + server_url, + allow_unverified_https=val.get("allow_unverified_https", False), + ) @property @override diff --git a/findmy/util/http.py b/findmy/util/http.py index 780a23d..59c1be8 100644 --- a/findmy/util/http.py +++ b/findmy/util/http.py @@ -13,6 +13,7 @@ from .abc import Closable from .parsers import decode_plist +from .tls import tls_setting logger = logging.getLogger(__name__) @@ -71,11 +72,23 @@ def plist(self) -> dict[Any, Any]: class HttpSession(Closable): """Asynchronous HTTP session manager. For internal use only.""" - def __init__(self) -> None: # noqa: D107 + def __init__(self, *, verify_tls: bool = True) -> None: + """ + Initialize the session. + + :param verify_tls: Whether to verify server certificates. **Leave this alone.** + Every Apple host this library talks to verifies against + :func:`~findmy.util.tls.apple_trust_context`, and turning this off makes each + request readable and alterable by anything on the network path -- a login + included. The one case it exists for is a self-hosted Anisette server with a + self-signed certificate, which is why the switch a caller actually sees is on + that provider rather than here. + """ super().__init__() self._session: ClientSession | None = None self._closed: bool = False + self._ssl = tls_setting(verify=verify_tls) async def _get_session(self) -> ClientSession: if self._closed: @@ -133,7 +146,7 @@ async def request( async with await session.request( method, url, - ssl=False, + ssl=self._ssl, raise_for_status=auto_retry, **options, ) as r: diff --git a/findmy/util/tls.py b/findmy/util/tls.py new file mode 100644 index 0000000..3b94f61 --- /dev/null +++ b/findmy/util/tls.py @@ -0,0 +1,130 @@ +""" +The trust store this library verifies Apple's servers against. + +**`gsa.apple.com` presents a chain rooted at the 2006 "Apple Root CA", which most trust +stores do not carry.** It is absent from certifi's bundle entirely, so anywhere that bundle +is in use -- which is most Linux deployments and every container built from one -- the +authentication host fails verification while every other Apple host this library talks to +verifies fine: + + ssl.SSLCertVerificationError: certificate verify failed: self-signed certificate in + certificate chain + +That one host is the whole reason verification used to be disabled process-wide, on every +request the library makes. The rest -- CloudKit, the setup delegate, the iCloud gateway -- +verify fine today and were being sent unverified for no reason at all, carrying a login +among other things. + +So the fix is to **add** the missing root rather than to stop checking: a context built +from the platform defaults, with Apple's root loaded on top. Adding a trust anchor does not +weaken the ones already there, and this is strictly stricter than what a bare +`create_default_context()` gives on a machine whose store happens to include it. + +Note what this deliberately is *not*. It does not pin a leaf or an intermediate: a general +TLS path has to survive certificate rotation, and a client that pins one breaks on a day +Apple chooses. Adding a root is the right strength here. +""" + +from __future__ import annotations + +import hashlib +import logging +import ssl +from functools import lru_cache + +logger = logging.getLogger(__name__) + +APPLE_ROOT_CA_PEM = """-----BEGIN CERTIFICATE----- +MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET +MBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0 +MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw +bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx +FjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg+ ++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1 +XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9w +tj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IW +q6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKM +aLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3 +R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAE +ggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93 +d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNl +IG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0 +YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBj +b25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZp +Y2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBc +NplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQP +y3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7 +R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4Fg +xhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oP +IQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX +UKqK1drk/NAJBzewdXUh +-----END CERTIFICATE----- +""" +""" +Apple Root CA, serial 2, valid from 2006 to 2035. + +Published by Apple at . Checked against its SHA-256 at +import, so a mangled copy fails immediately rather than at the first handshake. +""" + +APPLE_ROOT_CA_SHA256 = bytes.fromhex( + "b0b1730ecbc7ff4505142c49f1295e6eda6bcaed7e2c68c5be91b5a11001f024", +) +"""What the root above must hash to. A certificate is only a trust anchor once it does.""" + + +class TlsError(Exception): + """Raised when the trust store cannot be built.""" + + +def _verified_root() -> str: + """Return the bundled root, having checked it is the certificate it claims to be.""" + digest = hashlib.sha256(ssl.PEM_cert_to_DER_cert(APPLE_ROOT_CA_PEM)).digest() + if digest != APPLE_ROOT_CA_SHA256: + msg = ( + f"The bundled Apple root hashes to {digest.hex()}, not" + f" {APPLE_ROOT_CA_SHA256.hex()}. Refusing to trust it." + ) + raise TlsError(msg) + return APPLE_ROOT_CA_PEM + + +@lru_cache(maxsize=1) +def apple_trust_context() -> ssl.SSLContext: + """ + Build the context every request to Apple is verified against. + + The platform's own trust store plus Apple's 2006 root, which most stores lack and which + `gsa.apple.com` chains to. Cached because constructing one reads certificates from disk, + and that is blocking work best not done per request. + """ + context = ssl.create_default_context() + try: + context.load_verify_locations(cadata=_verified_root()) + except ssl.SSLError as e: + msg = f"Could not load the bundled Apple root: {e}" + raise TlsError(msg) from None + + return context + + +def tls_setting(*, verify: bool) -> ssl.SSLContext | bool: + """ + Resolve what to pass as aiohttp's `ssl` argument. + + :param verify: False disables certificate verification entirely, which makes the + connection interceptable by anything on the network path. There is exactly one + legitimate reason to do it -- an Anisette server of one's own with a self-signed + certificate -- and it is exposed there and nowhere else. + """ + if verify: + return apple_trust_context() + + logger.warning( + "TLS certificate verification is disabled for this session. Anything on the" + " network path can read and alter these requests.", + ) + return False diff --git a/tests/test_tls.py b/tests/test_tls.py new file mode 100644 index 0000000..2b8abd2 --- /dev/null +++ b/tests/test_tls.py @@ -0,0 +1,139 @@ +"""Tests for the trust store, and for verification being on unless it is turned off.""" + +from __future__ import annotations + +import hashlib +import ssl + +import pytest + +from findmy.reports.anisette import RemoteAnisetteProvider +from findmy.util.http import HttpSession +from findmy.util.tls import ( + APPLE_ROOT_CA_PEM, + APPLE_ROOT_CA_SHA256, + TlsError, + apple_trust_context, + tls_setting, +) + + +def _common_names(context: ssl.SSLContext) -> set[str]: + """Every trust anchor the context holds, by common name.""" + return { + value + for certificate in context.get_ca_certs() + for group in certificate.get("subject", ()) + for key, value in group + if key == "commonName" + } + + +def test_the_bundled_root_is_the_certificate_it_claims_to_be() -> None: + """Test that the bundled root hashes to its published fingerprint.""" + digest = hashlib.sha256(ssl.PEM_cert_to_DER_cert(APPLE_ROOT_CA_PEM)).digest() + + assert digest == APPLE_ROOT_CA_SHA256 + assert digest.hex() == "b0b1730ecbc7ff4505142c49f1295e6eda6bcaed7e2c68c5be91b5a11001f024" + + +def test_a_root_that_does_not_match_its_fingerprint_is_refused( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that a root failing its own fingerprint is refused rather than trusted.""" + # A trust anchor is only an anchor once it hashes to what it should. Catching a mangled + # copy here means failing at import rather than at the first handshake, where it would + # read as Apple's server being unreachable. + monkeypatch.setattr("findmy.util.tls.APPLE_ROOT_CA_SHA256", b"\x00" * 32) + apple_trust_context.cache_clear() + + with pytest.raises(TlsError, match="Refusing to trust it"): + apple_trust_context() + + apple_trust_context.cache_clear() + + +def test_the_context_adds_apples_root_to_the_platforms_own() -> None: + """Test that Apple's root is added on top of the platform's trust store.""" + context = apple_trust_context() + + assert "Apple Root CA" in _common_names(context) + # The check above is only worth making if it can fail: an empty context has no anchors, + # and this is what distinguishes "the root was loaded" from "the helper always says + # yes". The platform store cannot serve as the negative case -- macOS ships this root + # and Mozilla-derived stores do not, which is the whole reason for bundling it. + assert "Apple Root CA" not in _common_names(ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)) + + # Added *on top of* the defaults, not instead of them: adding a trust anchor does not + # weaken the ones already there, and a context holding only Apple's root would refuse + # every other host this library talks to. + # + # Compared as a subset rather than by counting. Whether the platform's own store is + # loaded eagerly from a file or lazily from a hashed directory differs by platform and + # by Python build -- `get_ca_certs()` returns one entry on a runner that loads lazily + # and hundreds on one that does not -- so a count measures the machine, not this code. + assert _common_names(ssl.create_default_context()) <= _common_names(context) + + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.check_hostname + + +def test_verification_is_on_unless_it_is_turned_off() -> None: + """Test that the default resolves to the verifying context.""" + assert tls_setting(verify=True) is apple_trust_context() + assert tls_setting(verify=False) is False + + +def test_disabling_verification_says_so(caplog: pytest.LogCaptureFixture) -> None: + """Test that turning verification off is logged rather than silent.""" + with caplog.at_level("WARNING"): + tls_setting(verify=False) + + assert "network path" in caplog.text + + +# These construct a session without ever making a request, so no aiohttp session is +# created and there is nothing to close -- which keeps them synchronous. + + +def test_an_http_session_verifies_by_default() -> None: + """Test that a session verifies unless told otherwise.""" + assert HttpSession()._ssl is apple_trust_context() # noqa: SLF001 + + +def test_an_http_session_can_be_told_not_to() -> None: + """Test that a session can be built without verification.""" + assert HttpSession(verify_tls=False)._ssl is False # noqa: SLF001 + + +def test_an_anisette_provider_verifies_by_default() -> None: + """Test that a provider verifies, and writes no flag when it does.""" + provider = RemoteAnisetteProvider("https://ani.example/") + + assert provider._http._ssl is apple_trust_context() # noqa: SLF001 + assert provider.to_json() == {"type": "aniRemote", "url": "https://ani.example/"} + + +def test_the_opt_in_reaches_the_session_and_survives_a_round_trip() -> None: + """Test that the opt-in reaches the session and is restored from JSON.""" + # It has to be serialized, or saving and reloading an account would silently turn a + # working self-signed setup into a failing one. + provider = RemoteAnisetteProvider("https://ani.example/", allow_unverified_https=True) + assert provider._http._ssl is False # noqa: SLF001 + + state = provider.to_json() + # `.get`, because the key is `NotRequired` on the mapping -- it is written only when + # true, so a subscript is an access the type checker is right to object to. The + # assertion is unchanged: present, and true. + assert state.get("allow_unverified_https") is True + + assert RemoteAnisetteProvider.from_json(state)._http._ssl is False # noqa: SLF001 + + +def test_an_older_saved_provider_still_loads_and_verifies() -> None: + """Test that a provider saved before this option existed still verifies.""" + # A file written before this option existed carries no flag, and must keep working -- + # verifying, which is the safe direction for a default to move in. + restored = RemoteAnisetteProvider.from_json({"type": "aniRemote", "url": "https://a/"}) + + assert restored._http._ssl is apple_trust_context() # noqa: SLF001