For reading and verification only. You may not copy, use, modify or redistribute this code. Full notice on the main page.
src/all_mcp/tls_store.py
| 1 | """ALL 2.0: the device's TLS key and certificate chain on disk, as GENERATIONS (plan 2.0, sections 2 and 4). |
| 2 | |
| 3 | Every issuance makes a NEW EC P-256 key and writes it, together with the chain the CA returns for it, into its own folder ``tls/gen-<n>/``. |
| 4 | Which generation serves is a tiny file, ``tls/current``, that is only ever replaced by an atomic rename. So: |
| 5 | |
| 6 | * the key that is serving is never overwritten, and the pointer never names a generation whose chain is not on disk and matches its key; |
| 7 | * a crash at any moment leaves the previous pointer (and so the previous certificate) in force, plus at worst a folder that nothing names; |
| 8 | * the previous generation is kept until the new one has served a handshake (``mark_served``), so a certificate that turns out not to work can |
| 9 | still be gone back to; |
| 10 | * nothing here follows a link: every file goes through ``_device_storage.PrivateStore`` (no symbolic links, one hard link, this user's, 0600, |
| 11 | in a 0700 folder; an owner-only DACL on Windows), exactly like the device identity. |
| 12 | |
| 13 | Layout of ``tls/`` (the folder is 0700, every file 0600): |
| 14 | current {"v":1,"gen":n,"previous":m|null,"served":bool} the pointer |
| 15 | gen-<n>/ device.key, fullchain.pem, meta.json (host, CA, validity, SPKI hash) |
| 16 | state.json what the certificate manager remembers between runs (attempts, back-off, ARI, a confirmation still to send) |
| 17 | status.json what the tray, the runner and the fleet heartbeat read (no secret, never a TXT value) |
| 18 | spki-log append-only, one JSON line per key this device ever made: the anchor of the device's own CT self-watch (WP36) |
| 19 | ct-watch.json what the CT self-watch remembers: its alerts, their acknowledgements, the last look at CT (e2e_ct_watch.py; ct-watch.lock is its lock) |
| 20 | key-storage.json how the keys are kept (a file today) and whether a native store could hold them; it never changes by itself |
| 21 | acme-account.key, acme-account.json, manager.lock |
| 22 | |
| 23 | Constants come from contracts/policy.json (the copy in e2e_policy.json), never from here. |
| 24 | """ |
| 25 | from __future__ import annotations |
| 26 | |
| 27 | import base64 |
| 28 | import datetime |
| 29 | import errno |
| 30 | import functools |
| 31 | import hashlib |
| 32 | import json |
| 33 | import logging |
| 34 | import os |
| 35 | import re |
| 36 | import secrets |
| 37 | import shutil |
| 38 | import ssl |
| 39 | import threading |
| 40 | import time |
| 41 | from collections.abc import Callable, Iterator |
| 42 | from contextlib import contextmanager |
| 43 | from dataclasses import dataclass, field |
| 44 | from pathlib import Path |
| 45 | from typing import Any |
| 46 | |
| 47 | from cryptography import x509 |
| 48 | from cryptography.hazmat.primitives import hashes, serialization |
| 49 | from cryptography.hazmat.primitives.asymmetric import ec |
| 50 | |
| 51 | from . import tls_policy |
| 52 | from ._device_storage import CredentialStorageError, LockTimeout, PrivateStore, held_lock |
| 53 | from .config import state_dir |
| 54 | |
| 55 | log = logging.getLogger("all_mcp.tls_store") |
| 56 | |
| 57 | POINTER = "current" |
| 58 | STATE = "state.json" |
| 59 | STATUS = "status.json" |
| 60 | SPKI_LOG = "spki-log" |
| 61 | KEY_FILE = "device.key" |
| 62 | CHAIN_FILE = "fullchain.pem" |
| 63 | META_FILE = "meta.json" |
| 64 | KEY_STORAGE = "key-storage.json" |
| 65 | LOCK_FILE = "manager.lock" |
| 66 | GENERATION_RE = re.compile(r"gen-([1-9][0-9]{0,8})") |
| 67 | TEMPORARY_PREFIX = ".tmp-" |
| 68 | SCHEMA_VERSION = 1 |
| 69 | MAX_POINTER_BYTES = 4096 |
| 70 | UNSAFE_ERRNOS = {errno.ELOOP, errno.ENOTDIR, errno.EISDIR, errno.EPERM, errno.EACCES} |
| 71 | |
| 72 | |
| 73 | class TlsStoreError(Exception): |
| 74 | """``word`` is a closed word (never text from the network) the status file and the heartbeat may carry.""" |
| 75 | |
| 76 | def __init__(self, word: str, message: str = ""): |
| 77 | super().__init__(message or word) |
| 78 | self.word = word |
| 79 | |
| 80 | |
| 81 | @functools.lru_cache(maxsize=1) |
| 82 | def policy() -> dict[str, Any]: |
| 83 | """The device's copy of contracts/policy.json (the contract generator sync keeps it byte-identical).""" |
| 84 | return json.loads(Path(__file__).with_name("e2e_policy.json").read_text(encoding="utf-8")) |
| 85 | |
| 86 | |
| 87 | def certificate_policy() -> dict[str, Any]: |
| 88 | return policy()["certificates"] |
| 89 | |
| 90 | |
| 91 | def b64url(data: bytes) -> str: |
| 92 | return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") |
| 93 | |
| 94 | |
| 95 | # ---------------------------------------------------------------------------------------------------------------- reading a chain |
| 96 | |
| 97 | @dataclass(frozen=True) |
| 98 | class CertInfo: |
| 99 | names: tuple[str, ...] |
| 100 | not_before: float |
| 101 | not_after: float |
| 102 | fingerprint: str # SHA-256 of the leaf, hex |
| 103 | issuer: str |
| 104 | |
| 105 | @property |
| 106 | def renew_at(self) -> float: |
| 107 | """When only the share of the life that policy.certificates.renew_backstop_divisor names is left (a third: day 30 of 45, day 60 of 90).""" |
| 108 | return self.not_after - (self.not_after - self.not_before) / certificate_policy()["renew_backstop_divisor"] |
| 109 | |
| 110 | |
| 111 | def _utc(certificate: x509.Certificate, attribute: str) -> float: |
| 112 | value = getattr(certificate, attribute + "_utc", None) |
| 113 | if value is None: # cryptography < 42 |
| 114 | value = getattr(certificate, attribute).replace(tzinfo=datetime.timezone.utc) |
| 115 | return value.timestamp() |
| 116 | |
| 117 | |
| 118 | def parse_chain(pem: bytes) -> list[x509.Certificate]: |
| 119 | certificates = x509.load_pem_x509_certificates(pem) |
| 120 | if not certificates: |
| 121 | raise ValueError("no certificate in the chain") |
| 122 | return certificates |
| 123 | |
| 124 | |
| 125 | def inspect_chain(pem: bytes) -> CertInfo: |
| 126 | leaf = parse_chain(pem)[0] |
| 127 | try: |
| 128 | names = tuple(leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(x509.DNSName)) |
| 129 | except x509.ExtensionNotFound: |
| 130 | names = () |
| 131 | return CertInfo(names=tuple(n.lower() for n in names), not_before=_utc(leaf, "not_valid_before"), not_after=_utc(leaf, "not_valid_after"), |
| 132 | fingerprint=leaf.fingerprint(hashes.SHA256()).hex(), issuer=leaf.issuer.rfc4514_string()) |
| 133 | |
| 134 | |
| 135 | def _public_der(public_key) -> bytes: |
| 136 | return public_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) |
| 137 | |
| 138 | |
| 139 | def chain_matches_key(pem: bytes, key) -> bool: |
| 140 | return _public_der(parse_chain(pem)[0].public_key()) == _public_der(key.public_key()) |
| 141 | |
| 142 | |
| 143 | def spki_sha256(public_key) -> str: |
| 144 | """base64url (43 characters) of the SHA-256 of the SubjectPublicKeyInfo: what the broker records before any order and what CT is compared with.""" |
| 145 | return b64url(hashlib.sha256(_public_der(public_key)).digest()) |
| 146 | |
| 147 | |
| 148 | def key_pem(key) -> bytes: |
| 149 | return key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) |
| 150 | |
| 151 | |
| 152 | def new_key() -> ec.EllipticCurvePrivateKey: |
| 153 | return ec.generate_private_key(ec.SECP256R1()) |
| 154 | |
| 155 | |
| 156 | def leaf_der(pem: bytes) -> bytes: |
| 157 | return parse_chain(pem)[0].public_bytes(serialization.Encoding.DER) |
| 158 | |
| 159 | |
| 160 | # ---------------------------------------------------------------------------------------------------------------- one generation |
| 161 | |
| 162 | @dataclass(frozen=True) |
| 163 | class Generation: |
| 164 | number: int |
| 165 | key_path: Path |
| 166 | chain_path: Path |
| 167 | info: CertInfo |
| 168 | spki: str |
| 169 | meta: dict[str, Any] |
| 170 | chain_pem: bytes = field(default=b"", repr=False) |
| 171 | |
| 172 | @property |
| 173 | def ca(self) -> str: |
| 174 | return str(self.meta.get("ca") or "") |
| 175 | |
| 176 | @property |
| 177 | def renew_at(self) -> float: |
| 178 | return self.info.renew_at |
| 179 | |
| 180 | |
| 181 | def _pointer_value(document: Any) -> dict[str, Any]: |
| 182 | if (not isinstance(document, dict) or document.get("v") != SCHEMA_VERSION or type(document.get("gen")) is not int or document["gen"] < 1 |
| 183 | or not (document.get("previous") is None or (type(document["previous"]) is int and document["previous"] >= 1)) |
| 184 | or not isinstance(document.get("served"), bool)): |
| 185 | raise TlsStoreError("pointer_invalid") |
| 186 | return {"v": SCHEMA_VERSION, "gen": document["gen"], "previous": document.get("previous"), "served": document["served"]} |
| 187 | |
| 188 | |
| 189 | class TlsStore: |
| 190 | """Everything under ``tls/``. ``faults`` is a hook for the tests (and only them): it is called with the name of a step of an issuance |
| 191 | ("after_key", "mid_chain", "before_swap", "after_swap") and may raise to stop the process exactly there.""" |
| 192 | |
| 193 | def __init__(self, directory: Path | None = None, *, faults: Callable[[str], None] | None = None): |
| 194 | self.dir = Path(directory) if directory is not None else state_dir() / "tls" |
| 195 | self.faults = faults |
| 196 | |
| 197 | def hit(self, point: str) -> None: |
| 198 | if self.faults is not None: |
| 199 | self.faults(point) |
| 200 | |
| 201 | # -- access |
| 202 | @contextmanager |
| 203 | def _open(self, *, create: bool = True) -> Iterator[PrivateStore | None]: |
| 204 | """The folder, opened through ``PrivateStore`` (so a link, a stranger's folder or a wrong mode refuses). Unsafe storage is |
| 205 | ``TlsStoreError("storage_unsafe")``; with ``create=False`` a folder that does not exist yields None.""" |
| 206 | try: |
| 207 | with self._guard(): |
| 208 | store = PrivateStore(self.dir, create=create) |
| 209 | except FileNotFoundError: |
| 210 | yield None |
| 211 | return |
| 212 | try: |
| 213 | with self._guard(): |
| 214 | yield store |
| 215 | finally: |
| 216 | store.close() |
| 217 | |
| 218 | @staticmethod |
| 219 | @contextmanager |
| 220 | def _guard() -> Iterator[None]: |
| 221 | """What the storage checks refuse (a link, a stranger's file, a wrong mode, a second hard link) is ``storage_unsafe``; any other failure of the |
| 222 | disk (full, read-only, an I/O error) is ``storage_error``: not a reason to distrust the folder, and not something to repair by deleting.""" |
| 223 | try: |
| 224 | yield |
| 225 | except (TlsStoreError, LockTimeout, FileNotFoundError): |
| 226 | raise |
| 227 | except CredentialStorageError: |
| 228 | raise TlsStoreError("storage_unsafe") from None |
| 229 | except OSError as exc: |
| 230 | raise TlsStoreError("storage_unsafe" if exc.errno in UNSAFE_ERRNOS else "storage_error") from None |
| 231 | |
| 232 | @contextmanager |
| 233 | def exclusive(self, timeout: float = 0.0) -> Iterator[None]: |
| 234 | """One certificate manager at a time, across processes (a second pass that finds it taken gets ``LockTimeout`` at once).""" |
| 235 | with self._open() as store, held_lock(store, LOCK_FILE, timeout): |
| 236 | yield |
| 237 | |
| 238 | @contextmanager |
| 239 | def locked(self, name: str, timeout: float = 5.0) -> Iterator[None]: |
| 240 | """A lock of its own on the private file ``name`` for a reader-modifier of one small record (the CT self-watch's state, e2e_ct_watch.py). It is not |
| 241 | ``exclusive``: that one is held for a whole issuance, and a record the tray rewrites must not wait for it.""" |
| 242 | with self._open() as store, held_lock(store, name, timeout): |
| 243 | yield |
| 244 | |
| 245 | def _atomic(self, store: PrivateStore, name: str, data: bytes, *, fault: str | None = None) -> None: |
| 246 | """Write ``data`` to ``name`` so that a reader sees the old file or the whole new one: a private temporary file (created 0600, so there is |
| 247 | no moment with wider permissions), fsync, atomic rename, fsync of the folder. With ``fault`` the step of that name fires half way through the |
| 248 | file, which is where a power loss leaves a half-written temporary and nothing else.""" |
| 249 | temporary = f"{TEMPORARY_PREFIX}{secrets.token_hex(8)}" |
| 250 | descriptor = store.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL) |
| 251 | try: |
| 252 | with os.fdopen(descriptor, "wb") as stream: |
| 253 | half = len(data) // 2 if fault else len(data) |
| 254 | stream.write(data[:half]) |
| 255 | stream.flush() |
| 256 | if fault: |
| 257 | self.hit(fault) |
| 258 | stream.write(data[half:]) |
| 259 | stream.flush() |
| 260 | os.fsync(stream.fileno()) |
| 261 | store.rename(temporary, name) |
| 262 | store.sync() |
| 263 | except Exception: # not BaseException: a simulated crash leaves the temporary behind, like the real thing (gc removes it) |
| 264 | store.remove(temporary) |
| 265 | raise |
| 266 | |
| 267 | @staticmethod |
| 268 | def _json(store: PrivateStore, name: str) -> Any | None: |
| 269 | raw = store.read(name) |
| 270 | if raw is None: |
| 271 | return None |
| 272 | try: |
| 273 | return json.loads(raw.decode("utf-8")) |
| 274 | except (ValueError, UnicodeError): |
| 275 | raise TlsStoreError("file_invalid", name) from None |
| 276 | |
| 277 | # -- the pointer |
| 278 | def pointer(self) -> dict[str, Any] | None: |
| 279 | with self._open(create=False) as store: |
| 280 | return None if store is None else self._pointer(store) |
| 281 | |
| 282 | def _pointer(self, store: PrivateStore) -> dict[str, Any] | None: |
| 283 | raw = store.read(POINTER) |
| 284 | if raw is None: |
| 285 | return None |
| 286 | if len(raw) > MAX_POINTER_BYTES: |
| 287 | raise TlsStoreError("pointer_invalid") |
| 288 | try: |
| 289 | return _pointer_value(json.loads(raw.decode("utf-8"))) |
| 290 | except (ValueError, UnicodeError): |
| 291 | raise TlsStoreError("pointer_invalid") from None |
| 292 | |
| 293 | def _numbers(self, store: PrivateStore) -> list[int]: |
| 294 | return sorted(int(m.group(1)) for name in store.names() if (m := GENERATION_RE.fullmatch(name)) and store.kind(name) == "dir") |
| 295 | |
| 296 | # -- generations |
| 297 | def new_generation(self) -> tuple[int, ec.EllipticCurvePrivateKey]: |
| 298 | """A new key, written 0600 into a folder of its own (never over a live key), and its SPKI hash added to ``spki-log``. Nothing is |
| 299 | served from it until ``write_chain`` and ``activate``.""" |
| 300 | key = new_key() |
| 301 | with self._open() as store: |
| 302 | pointer = None |
| 303 | try: |
| 304 | pointer = self._pointer(store) |
| 305 | except TlsStoreError: |
| 306 | pass # a pointer that cannot be read does not decide the number: the folders do |
| 307 | named = [pointer["gen"], pointer["previous"] or 0] if pointer else [] |
| 308 | number = max([*self._numbers(store), *named], default=0) + 1 |
| 309 | folder = store.child(f"gen-{number}", create=True) |
| 310 | try: |
| 311 | self._atomic(folder, KEY_FILE, key_pem(key)) |
| 312 | finally: |
| 313 | folder.close() |
| 314 | line = json.dumps({"v": SCHEMA_VERSION, "spki_sha256": spki_sha256(key.public_key()), "gen": number, "at": int(time.time())}, |
| 315 | separators=(",", ":")) + "\n" |
| 316 | store.append(SPKI_LOG, line.encode("ascii")) |
| 317 | self.hit("after_key") |
| 318 | return number, key |
| 319 | |
| 320 | def write_chain(self, number: int, chain_pem: bytes, host: str, meta: dict[str, Any]) -> Generation: |
| 321 | """Put the CA's chain beside the key it was issued for, and read the pair back from disk to validate it (the chain's leaf carries THIS key, |
| 322 | covers ``host``). A pair that does not match raises ``pair_mismatch`` and leaves nothing that could be activated.""" |
| 323 | with self._open() as store: |
| 324 | folder = store.child(f"gen-{number}") |
| 325 | try: |
| 326 | key = self._read_key(folder) |
| 327 | self._check_pair(chain_pem, key, host) |
| 328 | self._atomic(folder, CHAIN_FILE, chain_pem, fault="mid_chain") |
| 329 | document = {"v": SCHEMA_VERSION, "host": host, **meta, "spki_sha256": spki_sha256(key.public_key())} |
| 330 | self._atomic(folder, META_FILE, json.dumps(document, sort_keys=True, separators=(",", ":")).encode("utf-8")) |
| 331 | finally: |
| 332 | folder.close() |
| 333 | return self._load(store, number) |
| 334 | |
| 335 | @staticmethod |
| 336 | def _read_key(folder: PrivateStore) -> ec.EllipticCurvePrivateKey: |
| 337 | raw = folder.read(KEY_FILE) |
| 338 | if raw is None: |
| 339 | raise TlsStoreError("incomplete") |
| 340 | try: |
| 341 | key = serialization.load_pem_private_key(raw, password=None) |
| 342 | except (ValueError, TypeError): |
| 343 | raise TlsStoreError("key_invalid") from None |
| 344 | if not isinstance(key, ec.EllipticCurvePrivateKey) or not isinstance(key.curve, ec.SECP256R1): |
| 345 | raise TlsStoreError("key_invalid") |
| 346 | return key |
| 347 | |
| 348 | @staticmethod |
| 349 | def _check_pair(chain_pem: bytes, key, host: str) -> CertInfo: |
| 350 | try: |
| 351 | info = inspect_chain(chain_pem) |
| 352 | if not chain_matches_key(chain_pem, key): |
| 353 | raise TlsStoreError("pair_mismatch") |
| 354 | except ValueError: |
| 355 | raise TlsStoreError("chain_invalid") from None |
| 356 | if host and host.lower() not in info.names: |
| 357 | raise TlsStoreError("pair_mismatch", "the certificate does not cover this device's name") |
| 358 | return info |
| 359 | |
| 360 | def _load(self, store: PrivateStore, number: int) -> Generation: |
| 361 | try: |
| 362 | folder = store.child(f"gen-{number}") |
| 363 | except FileNotFoundError: |
| 364 | raise TlsStoreError("incomplete") from None |
| 365 | try: |
| 366 | key = self._read_key(folder) |
| 367 | chain = folder.read(CHAIN_FILE) |
| 368 | if chain is None: |
| 369 | raise TlsStoreError("incomplete") |
| 370 | meta = self._json(folder, META_FILE) |
| 371 | finally: |
| 372 | folder.close() |
| 373 | meta = meta if isinstance(meta, dict) else {} |
| 374 | info = self._check_pair(chain, key, str(meta.get("host") or "")) |
| 375 | return Generation(number, self.dir / f"gen-{number}" / KEY_FILE, self.dir / f"gen-{number}" / CHAIN_FILE, info, spki_sha256(key.public_key()), meta, chain) |
| 376 | |
| 377 | def generation(self, number: int) -> Generation | None: |
| 378 | with self._open(create=False) as store: |
| 379 | if store is None: |
| 380 | return None |
| 381 | try: |
| 382 | return self._load(store, number) |
| 383 | except TlsStoreError: |
| 384 | return None |
| 385 | |
| 386 | def generations(self) -> list[int]: |
| 387 | with self._open(create=False) as store: |
| 388 | return [] if store is None else self._numbers(store) |
| 389 | |
| 390 | def active(self) -> Generation | None: |
| 391 | """The generation to serve: the one the pointer names; when that one cannot be read as a valid pair, the previous one; else None.""" |
| 392 | with self._open(create=False) as store: |
| 393 | if store is None: |
| 394 | return None |
| 395 | try: |
| 396 | pointer = self._pointer(store) |
| 397 | except TlsStoreError: |
| 398 | return None |
| 399 | if pointer is None: |
| 400 | return None |
| 401 | for number in (pointer["gen"], pointer["previous"]): |
| 402 | if number is None: |
| 403 | continue |
| 404 | try: |
| 405 | return self._load(store, number) |
| 406 | except TlsStoreError as exc: |
| 407 | log.warning("generation %s is not usable (%s)", number, exc.word) |
| 408 | return None |
| 409 | |
| 410 | def activate(self, number: int) -> Generation: |
| 411 | """Make ``number`` the serving generation: validate the pair again from disk, then replace the pointer by an atomic rename. The generation that |
| 412 | was serving becomes ``previous`` and stays until ``mark_served``.""" |
| 413 | with self._open() as store: |
| 414 | generation = self._load(store, number) |
| 415 | try: |
| 416 | old = self._pointer(store) |
| 417 | except TlsStoreError: |
| 418 | old = None |
| 419 | previous = old["gen"] if old and old["gen"] != number else (old["previous"] if old else None) |
| 420 | self._atomic(store, POINTER, json.dumps({"v": SCHEMA_VERSION, "gen": number, "previous": previous, "served": False}, separators=(",", ":")).encode()) |
| 421 | return generation |
| 422 | |
| 423 | def mark_served(self, number: int) -> bool: |
| 424 | """The terminator completed a handshake with generation ``number``: now the previous one can go. False when ``number`` is not the serving one.""" |
| 425 | with self._open(create=False) as store: |
| 426 | if store is None: |
| 427 | return False |
| 428 | pointer = self._pointer(store) |
| 429 | if pointer is None or pointer["gen"] != number: |
| 430 | return False |
| 431 | if pointer["served"] and pointer["previous"] is None: |
| 432 | return True |
| 433 | self._atomic(store, POINTER, json.dumps({"v": SCHEMA_VERSION, "gen": number, "previous": None, "served": True}, separators=(",", ":")).encode()) |
| 434 | if pointer["previous"] is not None: |
| 435 | store.remove_tree(f"gen-{pointer['previous']}") |
| 436 | return True |
| 437 | |
| 438 | def discard(self, number: int) -> None: |
| 439 | """Remove a generation that was never activated (a failed issuance). The serving and the previous one are refused.""" |
| 440 | with self._open() as store: |
| 441 | try: |
| 442 | pointer = self._pointer(store) |
| 443 | except TlsStoreError: |
| 444 | pointer = None |
| 445 | if pointer and number in (pointer["gen"], pointer["previous"]): |
| 446 | raise TlsStoreError("in_use") |
| 447 | store.remove_tree(f"gen-{number}") |
| 448 | |
| 449 | def resumable(self, host: str) -> list[Generation]: |
| 450 | """Complete, valid generations that nothing names, newest first: what a crash between "chain written" and "pointer swapped" leaves behind. |
| 451 | Adopting one saves a whole issuance (the CA already counted it).""" |
| 452 | found: list[Generation] = [] |
| 453 | with self._open(create=False) as store: |
| 454 | if store is None: |
| 455 | return found |
| 456 | try: |
| 457 | pointer = self._pointer(store) |
| 458 | except TlsStoreError: |
| 459 | pointer = None |
| 460 | named = {pointer["gen"], pointer["previous"]} if pointer else set() |
| 461 | for number in reversed(self._numbers(store)): |
| 462 | if number in named: |
| 463 | continue |
| 464 | try: |
| 465 | generation = self._load(store, number) |
| 466 | except TlsStoreError: |
| 467 | continue |
| 468 | if str(generation.meta.get("host") or "") == host and generation.meta.get("spki_sha256") == generation.spki: |
| 469 | found.append(generation) |
| 470 | return found |
| 471 | |
| 472 | def gc(self) -> list[str]: |
| 473 | """Remove what nothing names: generations that are neither the serving nor the previous one, and temporary files a crash left. Returns the |
| 474 | names removed. A folder that cannot be opened safely raises ``storage_unsafe`` and is left alone.""" |
| 475 | removed: list[str] = [] |
| 476 | with self._open() as store: |
| 477 | try: |
| 478 | pointer = self._pointer(store) |
| 479 | known = True |
| 480 | except TlsStoreError: |
| 481 | pointer, known = None, False # a pointer that cannot be read names nothing: so nothing may be thrown away because of it |
| 482 | keep = {f"gen-{pointer['gen']}", f"gen-{pointer['previous']}"} if pointer else set() |
| 483 | for name in store.names(): |
| 484 | if name.startswith(TEMPORARY_PREFIX) or (known and GENERATION_RE.fullmatch(name) and name not in keep): |
| 485 | store.remove_tree(name) |
| 486 | removed.append(name) |
| 487 | elif GENERATION_RE.fullmatch(name) and known: |
| 488 | folder = store.child(name) |
| 489 | try: |
| 490 | for entry in folder.names(): |
| 491 | if entry.startswith(TEMPORARY_PREFIX): |
| 492 | folder.remove(entry) |
| 493 | removed.append(f"{name}/{entry}") |
| 494 | finally: |
| 495 | folder.close() |
| 496 | return removed |
| 497 | |
| 498 | def destroy(self) -> None: |
| 499 | """Uninstall: everything under ``tls/`` goes, at once, with no network. The folder itself is removed too.""" |
| 500 | try: |
| 501 | with self._open(create=False) as store: |
| 502 | if store is None: |
| 503 | return |
| 504 | for name in store.names(): |
| 505 | store.remove_tree(name) |
| 506 | except TlsStoreError: |
| 507 | # a folder the checks refuse (a stranger's, a link): a link is only unlinked, never followed |
| 508 | if self.dir.is_symlink(): |
| 509 | self.dir.unlink() |
| 510 | else: |
| 511 | shutil.rmtree(self.dir, ignore_errors=True) |
| 512 | return |
| 513 | try: |
| 514 | self.dir.rmdir() |
| 515 | except OSError: |
| 516 | pass |
| 517 | |
| 518 | # -- records of the manager |
| 519 | def read_state(self) -> dict[str, Any] | None: |
| 520 | with self._open(create=False) as store: |
| 521 | if store is None: |
| 522 | return None |
| 523 | document = self._json(store, STATE) |
| 524 | return document if isinstance(document, dict) else None |
| 525 | |
| 526 | def write_state(self, state: dict[str, Any]) -> None: |
| 527 | with self._open() as store: |
| 528 | self._atomic(store, STATE, json.dumps(state, sort_keys=True, separators=(",", ":")).encode("utf-8")) |
| 529 | |
| 530 | def write_status(self, status: dict[str, Any]) -> None: |
| 531 | with self._open() as store: |
| 532 | self._atomic(store, STATUS, json.dumps(status, sort_keys=True, separators=(",", ":")).encode("utf-8")) |
| 533 | |
| 534 | def read_status(self) -> dict[str, Any] | None: |
| 535 | with self._open(create=False) as store: |
| 536 | if store is None: |
| 537 | return None |
| 538 | document = self._json(store, STATUS) |
| 539 | return document if isinstance(document, dict) else None |
| 540 | |
| 541 | def read_file(self, name: str) -> bytes | None: |
| 542 | with self._open(create=False) as store: |
| 543 | return None if store is None else store.read(name) |
| 544 | |
| 545 | def write_file(self, name: str, data: bytes) -> None: |
| 546 | with self._open() as store: |
| 547 | self._atomic(store, name, data) |
| 548 | |
| 549 | def spki_log(self) -> list[dict[str, Any]]: |
| 550 | """Every key this device made, oldest first (what WP36 compares certificates seen in CT with).""" |
| 551 | with self._open(create=False) as store: |
| 552 | raw = None if store is None else store.read(SPKI_LOG) |
| 553 | rows = [] |
| 554 | for line in (raw or b"").decode("ascii", "replace").splitlines(): |
| 555 | try: |
| 556 | row = json.loads(line) |
| 557 | except ValueError: |
| 558 | continue |
| 559 | if isinstance(row, dict) and isinstance(row.get("spki_sha256"), str): |
| 560 | rows.append(row) |
| 561 | return rows |
| 562 | |
| 563 | # -- how the keys are kept |
| 564 | def key_storage(self) -> dict[str, Any]: |
| 565 | """The descriptor of key storage. Keys are files (0600) today; ``native_eligible`` says which native store (macOS Keychain, Windows |
| 566 | credential vault, Secret Service) COULD hold a wrapping key, so a tray or an installer can OFFER it. The mode never changes by itself: not when a |
| 567 | native store appears, not when one goes away. A descriptor that says anything but "file" is refused (``key_storage_unsupported``) instead of |
| 568 | reading the files as if nothing was said.""" |
| 569 | with self._open() as store: |
| 570 | document = self._json(store, KEY_STORAGE) |
| 571 | if document is None: |
| 572 | document = {"v": SCHEMA_VERSION, "mode": "file", "wrapped_by": None, "change": "explicit"} |
| 573 | self._atomic(store, KEY_STORAGE, json.dumps(document, sort_keys=True, separators=(",", ":")).encode()) |
| 574 | if not isinstance(document, dict) or document.get("mode") != "file" or document.get("wrapped_by") is not None: |
| 575 | raise TlsStoreError("key_storage_unsupported") |
| 576 | return {**document, "native_eligible": native_store_name()} |
| 577 | |
| 578 | |
| 579 | def native_store_name() -> str | None: |
| 580 | """The native secret store this system could use to wrap the keys, or None. Looked up each time; nothing is switched by finding one.""" |
| 581 | try: |
| 582 | from .device_credentials import _native_backend |
| 583 | found = _native_backend() |
| 584 | except Exception: |
| 585 | return None |
| 586 | return found[0] if found else None |
| 587 | |
| 588 | |
| 589 | # ---------------------------------------------------------------------------------------------------------------- serving |
| 590 | |
| 591 | def server_context(chain_path: Path, key_path: Path) -> ssl.SSLContext: |
| 592 | """The terminator's context for one generation. The policy (minimum version, ALPN, the refusal to serve without TLS 1.3) is tls_policy's.""" |
| 593 | return tls_policy.server_context(chain_path, key_path) |
| 594 | |
| 595 | |
| 596 | class ServingContext: |
| 597 | """The TLS context a terminator serves with, following the pointer. A new generation is picked up at the NEXT handshake: connections that are |
| 598 | already established keep the context (the certificate) they began with, so a swap drops no stream. A generation that cannot be loaded never |
| 599 | replaces the one in use. |
| 600 | |
| 601 | serving = ServingContext(TlsStore()) |
| 602 | listener = asyncio.start_server(handler, ssl=serving.listener_context()) # new handshakes follow the pointer by themselves |
| 603 | ... after a handshake completed: serving.served() |
| 604 | """ |
| 605 | |
| 606 | def __init__(self, store: TlsStore, *, build: Callable[[Path, Path], ssl.SSLContext] = server_context): |
| 607 | self.store = store |
| 608 | self._build = build |
| 609 | self._lock = threading.Lock() |
| 610 | self._number: int | None = None |
| 611 | self._context: ssl.SSLContext | None = None |
| 612 | self.last_error = "" |
| 613 | |
| 614 | @property |
| 615 | def number(self) -> int | None: |
| 616 | return self._number |
| 617 | |
| 618 | def current(self) -> ssl.SSLContext | None: |
| 619 | """The context of the serving generation (loaded again when the pointer moved); the last good one when the pointer cannot be used; None |
| 620 | when there never was one.""" |
| 621 | with self._lock: |
| 622 | try: |
| 623 | pointer = self.store.pointer() |
| 624 | except TlsStoreError as exc: |
| 625 | self.last_error = exc.word |
| 626 | return self._context |
| 627 | if pointer is not None and pointer["gen"] == self._number and self._context is not None: |
| 628 | return self._context |
| 629 | try: |
| 630 | generation = self.store.active() |
| 631 | if generation is None: |
| 632 | return self._context |
| 633 | if generation.number != self._number: |
| 634 | self._context = self._build(generation.chain_path, generation.key_path) |
| 635 | self._number = generation.number |
| 636 | self.last_error = "" |
| 637 | except (ssl.SSLError, OSError, TlsStoreError, tls_policy.TlsUnavailable, ValueError) as exc: |
| 638 | self.last_error = getattr(exc, "word", type(exc).__name__) |
| 639 | log.warning("the new certificate could not be loaded (%s): the one in use stays", self.last_error) |
| 640 | return self._context |
| 641 | |
| 642 | def listener_context(self) -> ssl.SSLContext: |
| 643 | """A context for ONE listener that follows the pointer: every handshake is moved onto the context of the generation serving at that moment.""" |
| 644 | generation = self.store.active() |
| 645 | if generation is None: |
| 646 | raise TlsStoreError("no_generation") |
| 647 | listener = self._build(generation.chain_path, generation.key_path) # what answers until the callback moves the connection |
| 648 | |
| 649 | def follow(connection, _name, _context) -> None: |
| 650 | fresh = self.current() |
| 651 | if fresh is not None and fresh is not connection.context: |
| 652 | connection.context = fresh |
| 653 | |
| 654 | listener.sni_callback = follow |
| 655 | return listener |
| 656 | |
| 657 | def served(self) -> bool: |
| 658 | """A handshake completed with the generation in use: the previous generation can go.""" |
| 659 | number = self._number |
| 660 | return False if number is None else self.store.mark_served(number) |