ALL

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

SHA-256: f46ccf6a098f9063567d4e3cbe3fb2b2dc7d7f02782607b14c4db61440883c21
660 lines · 30620 bytes · View on GitHub

1"""ALL 2.0: the device's TLS key and certificate chain on disk, as GENERATIONS (plan 2.0, sections 2 and 4).
2
3Every 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>/``.
4Which 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
13Layout 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
23Constants come from contracts/policy.json (the copy in e2e_policy.json), never from here.
24"""
25from __future__ import annotations
26
27import base64
28import datetime
29import errno
30import functools
31import hashlib
32import json
33import logging
34import os
35import re
36import secrets
37import shutil
38import ssl
39import threading
40import time
41from collections.abc import Callable, Iterator
42from contextlib import contextmanager
43from dataclasses import dataclass, field
44from pathlib import Path
45from typing import Any
46
47from cryptography import x509
48from cryptography.hazmat.primitives import hashes, serialization
49from cryptography.hazmat.primitives.asymmetric import ec
50
51from . import tls_policy
52from ._device_storage import CredentialStorageError, LockTimeout, PrivateStore, held_lock
53from .config import state_dir
54
55log = logging.getLogger("all_mcp.tls_store")
56
57POINTER = "current"
58STATE = "state.json"
59STATUS = "status.json"
60SPKI_LOG = "spki-log"
61KEY_FILE = "device.key"
62CHAIN_FILE = "fullchain.pem"
63META_FILE = "meta.json"
64KEY_STORAGE = "key-storage.json"
65LOCK_FILE = "manager.lock"
66GENERATION_RE = re.compile(r"gen-([1-9][0-9]{0,8})")
67TEMPORARY_PREFIX = ".tmp-"
68SCHEMA_VERSION = 1
69MAX_POINTER_BYTES = 4096
70UNSAFE_ERRNOS = {errno.ELOOP, errno.ENOTDIR, errno.EISDIR, errno.EPERM, errno.EACCES}
71
72
73class 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)
82def 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
87def certificate_policy() -> dict[str, Any]:
88 return policy()["certificates"]
89
90
91def 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)
98class 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
111def _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
118def 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
125def 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
135def _public_der(public_key) -> bytes:
136 return public_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
137
138
139def chain_matches_key(pem: bytes, key) -> bool:
140 return _public_der(parse_chain(pem)[0].public_key()) == _public_der(key.public_key())
141
142
143def 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
148def key_pem(key) -> bytes:
149 return key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
150
151
152def new_key() -> ec.EllipticCurvePrivateKey:
153 return ec.generate_private_key(ec.SECP256R1())
154
155
156def 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)
163class 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
181def _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
189class 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
579def 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
591def 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
596class 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)