Skip to content
Open
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
59 changes: 44 additions & 15 deletions findmy/reports/anisette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
17 changes: 15 additions & 2 deletions findmy/util/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .abc import Closable
from .parsers import decode_plist
from .tls import tls_setting

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
130 changes: 130 additions & 0 deletions findmy/util/tls.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.apple.com/appleca/>. 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
Loading
Loading