**Phase 24 final verification — all green**
- **Verified (read-only re-run, 62/62 PASS)** via `data/logs/verify-24-mtls.py`: nine sources `ok`; `/api/certs` all three `active` with `deployed_bundle_rev == bundle_rev == 2`; `agent.tls: true` + `agent.version 1.1.0` on every host; driveripper 16/16 drives live; gateway absent from `/disks` but `disks:gateway ok`; all served target URLs `https://`, no userinfo; pre-flip backup present; no transport/cert alert.
- **Criterion 1 (export):** `data/certs/{driveripper,deskwork,gateway}.bundle` re-checked byte-identical (sha256) to `GET /api/certs/<name>/bundle`, mode `0600`, under gitignored `data/`; live arms re-probed (issued→file, unknown/`issued:false`→one-line note + no file, wrong password→exit 2, nothing written).
- **Criterion 2 (`--fetch-bundle`):** 54 sandbox pins in `tests/test_deploy_scripts.py` pass — byte-identical `0640` install + TLS unit lines, refusals (unissued / 401 / non-bundle body / dead dashboard) before `need_root` with no write, token absent from token file, unit, stdout and sudoers hint.
- **Criterion 3–4:** `./scripts/dev-server.sh status` nine `ok` + bundles block; screenshots `data/logs/24-task04-{certs,disks,system}.png` exist.
- **Criterion 7:** no behavior change — `agent/` untouched, `src/` diff is a `certs.ensure_certs` docstring only; token-mode, revocation-before-dial and bundle-route tests green.
- **Tests/lint:** `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov --cov-fail-under=94` → **1542 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `uv run pytest -m live --live` → **11 passed**.
- **Docs:** README.md (all 1144 lines) + AGENTS.md reviewed against the live project — every config.py env var is in README and both templates, route set / severity thresholds / test counts / coverage figure match, all repo paths, cited test names and `Dash.*`/`DashCharts.*` helpers resolve, phase-24 doc pins green. **Verified consistent — no changes needed.**
- **Notable:** left `fastapi 0.153.0 → 0.158.0` (validate reports it as advisory; no dependency was touched, rule 8 conditions the check on deps moving). Killed two abandoned `/tmp/fbtest` loopback stub processes left by task 02's manual probe; dev server unaffected.
- **Next pending phase:** `.agents/phases/todo/25_masthead_control_placement/`.
1237 lines
55 KiB
Python
1237 lines
55 KiB
Python
"""`src/dashboards/certs.py` — the dashboard's CA, its client cert, and every agent's, tested.
|
|
|
|
The lifecycle here is the part of phase 15 that cannot be checked in a browser: a root CA that
|
|
regenerates on restart silently untrusts every installed agent bundle; a bundle carrying the root
|
|
*key* turns one stolen agent install into a forged fleet; a revocation that lands on one host's
|
|
bundle but not the others is a revocation that did not happen. So this file is mostly claims about
|
|
**stored bytes**, and the store underneath them is a dict.
|
|
|
|
That is the `tests/test_auth_bootstrap.py` idiom, and it is why `certs.py` talks to a three-method
|
|
`MetaStore` protocol: the security-relevant branch has no SQLite in it, so the decisions are
|
|
asserted directly. Three tests at the end put something real underneath instead — the real
|
|
`SampleStore` for durability, a real `ssl` handshake for usability, and the real `create_app()`
|
|
lifespan for the fail-closed startup — because "it parses" is not the same claim as "an agent
|
|
accepts it".
|
|
|
|
Nothing sleeps and nothing reads the wall clock: `now` is an argument everywhere, so a 10-year root
|
|
and a 1-year leaf are asserted as numbers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import socket
|
|
import ssl
|
|
import tempfile
|
|
import threading
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from cryptography import x509
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
from cryptography.hazmat.primitives.asymmetric.types import PublicKeyTypes
|
|
from cryptography.hazmat.primitives.serialization import (
|
|
Encoding,
|
|
NoEncryption,
|
|
PrivateFormat,
|
|
PublicFormat,
|
|
load_pem_private_key,
|
|
)
|
|
from cryptography.x509.oid import NameOID
|
|
from fastapi.testclient import TestClient
|
|
|
|
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
|
from dashboards import auth, certs
|
|
from dashboards.app import create_app
|
|
from dashboards.config import Settings
|
|
from dashboards.store import SampleStore
|
|
|
|
CERTS_SOURCE = Path(__file__).resolve().parents[1] / "src" / "dashboards" / "certs.py"
|
|
|
|
#: One fixed instant for every lifetime assertion. `certs.py` never reads a clock, so an expiry is
|
|
#: this number plus the documented lifetime — no sleeps, no flaky window.
|
|
NOW = 1_700_000_000.0
|
|
|
|
CLIENT_CERT_FILE = "client-cert.pem"
|
|
CLIENT_KEY_FILE = "client-key.pem"
|
|
CA_FILE = "root-ca.pem"
|
|
|
|
|
|
class FakeMeta:
|
|
"""The three-method shape `certs.py` asks for, with no database in it.
|
|
|
|
Exactly how the auth bootstrap is tested: the lifecycle is a function over a key/value store,
|
|
so the store is a dict and the assertions are about the rows.
|
|
"""
|
|
|
|
def __init__(self, rows: dict[str, str] | None = None) -> None:
|
|
self.rows: dict[str, str] = dict(rows or {})
|
|
|
|
def get_meta(self, key: str) -> str | None:
|
|
return self.rows.get(key)
|
|
|
|
def set_meta(self, key: str, value: str) -> None:
|
|
self.rows[key] = value
|
|
|
|
def delete_meta(self, key: str) -> int:
|
|
return 1 if self.rows.pop(key, None) is not None else 0
|
|
|
|
|
|
def boot(*, now: float = NOW, rows: dict[str, str] | None = None
|
|
) -> tuple[FakeMeta, certs.CertsState]:
|
|
"""A store and the state one process start produced against it."""
|
|
store = FakeMeta(rows)
|
|
return store, certs.ensure_certs(store, now=now)
|
|
|
|
|
|
def issue(store: FakeMeta, state: certs.CertsState, name: str = "deskwork",
|
|
san: list[str] | None = None) -> certs.CertsState:
|
|
return certs.issue_agent_cert(store, state, name, san or [], now=NOW)
|
|
|
|
|
|
def rotate(store: FakeMeta, state: certs.CertsState, name: str = "deskwork",
|
|
san: list[str] | None = None) -> certs.CertsState:
|
|
return certs.reissue_agent_cert(store, state, name, san or [], now=NOW)
|
|
|
|
|
|
def revoke(store: FakeMeta, state: certs.CertsState, name: str) -> certs.CertsState:
|
|
return certs.revoke(store, state, name, now=NOW)
|
|
|
|
|
|
def root_of(state: certs.CertsState) -> x509.Certificate:
|
|
return cert_of(state.root_cert)
|
|
|
|
|
|
# --- reading the artifacts back, independently of the module -------------------------------- #
|
|
|
|
|
|
def blocks(text: str) -> list[str]:
|
|
"""Every PEM block in `text`, in order.
|
|
|
|
Reimplemented with a split rather than by calling the module's parser: a test that reads a
|
|
bundle with the code that wrote it cannot notice the format changing, and task 02's agent has
|
|
to parse it with its own stdlib code anyway.
|
|
"""
|
|
out: list[str] = []
|
|
for part in text.split("-----BEGIN ")[1:]:
|
|
label, _, rest = part.partition("-----\n")
|
|
body, _, _tail = rest.partition("-----END ")
|
|
out.append(f"-----BEGIN {label}-----\n{body}-----END {label}-----\n")
|
|
return out
|
|
|
|
|
|
def cert_of(text: str) -> x509.Certificate:
|
|
return x509.load_pem_x509_certificate(text.encode("ascii"))
|
|
|
|
|
|
def key_of(text: str) -> EllipticCurvePrivateKey:
|
|
key = load_pem_private_key(text.encode("ascii"), password=None)
|
|
assert isinstance(key, EllipticCurvePrivateKey), "this module issues P-256 keys"
|
|
return key
|
|
|
|
|
|
def public_id(public_key: PublicKeyTypes) -> bytes:
|
|
"""A public key's identity as its SPKI encoding.
|
|
|
|
Compared instead of `public_numbers()` because that attribute exists per algorithm and
|
|
`certificate.public_key()` is typed as the union of all of them; the encoding is the same
|
|
claim ("same key") and needs no cast.
|
|
"""
|
|
return public_key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
|
|
|
|
|
|
def entry_of(state: certs.CertsState, name: str) -> certs.AgentCert:
|
|
"""The entry for one host, asserted present — a missing row is the failure, not a `None`."""
|
|
entry = state.agent(name)
|
|
assert entry is not None, f"no entry stored for {name}"
|
|
return entry
|
|
|
|
|
|
def bundle_pems(bundle: str) -> dict[str, str]:
|
|
"""A bundle as `{cert, key, root}` — the agent's view of the same three blocks."""
|
|
found = blocks(bundle)
|
|
certs_found = [b for b in found if b.startswith("-----BEGIN CERTIFICATE")]
|
|
keys = [b for b in found if not b.startswith("-----BEGIN CERTIFICATE")]
|
|
assert len(certs_found) == 2 and len(keys) == 1, f"unexpected bundle shape: {found}"
|
|
return {"cert": certs_found[0], "key": keys[0], "root": certs_found[1]}
|
|
|
|
|
|
def revoked_lines(bundle: str) -> list[int]:
|
|
"""The `# REVOKED` denylist, as serials, in the order the bundle carries them."""
|
|
return [int(line.removeprefix(certs.REVOKED_LINE))
|
|
for line in bundle.splitlines() if line.startswith(certs.REVOKED_LINE)]
|
|
|
|
|
|
def rev_of(bundle: str) -> int:
|
|
lines = [line for line in bundle.splitlines() if line.startswith(certs.BUNDLE_REV_LINE)]
|
|
assert len(lines) == 1, "exactly one revision line"
|
|
return int(lines[0].removeprefix(certs.BUNDLE_REV_LINE))
|
|
|
|
|
|
def san_names(cert: x509.Certificate) -> list[str]:
|
|
return [str(item.value)
|
|
for item in cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value]
|
|
|
|
|
|
def eku_of(cert: x509.Certificate) -> list[str]:
|
|
extension = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
|
|
return [str(item.dotted_string) for item in extension]
|
|
|
|
|
|
def cn(cert: x509.Certificate) -> str:
|
|
return str(cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value)
|
|
|
|
|
|
# --- the module's own hygiene ---------------------------------------------------------------- #
|
|
|
|
|
|
def test_the_module_owns_five_meta_keys_and_names_them_all_in_the_reset() -> None:
|
|
"""The rows an operator has to be able to type, and the message that types them for them."""
|
|
assert certs.META_KEYS == ("certs.root.key", "certs.root.cert", "certs.client.key",
|
|
"certs.client.cert", "certs.agents")
|
|
for key in certs.META_KEYS:
|
|
assert key in certs.CORRUPT_STORE_MESSAGE
|
|
assert "DELETE FROM meta" in certs.CORRUPT_STORE_MESSAGE
|
|
assert "sqlite3" in certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
def _tree() -> ast.Module:
|
|
return ast.parse(CERTS_SOURCE.read_text())
|
|
|
|
|
|
def test_the_module_imports_nothing_from_the_package_and_nothing_logs() -> None:
|
|
"""The `auth.py` rule: standalone, testable with a dict, with no path from a key to a log line.
|
|
|
|
A private key is a credential the same way a password is, and this app turns log lines and
|
|
exception messages into things a browser can read (`AGENTS.md` invariant 6). Keeping `logging`
|
|
out of the file entirely is the version of that rule a test can actually hold.
|
|
"""
|
|
names: set[str] = set()
|
|
for node in ast.walk(_tree()):
|
|
if isinstance(node, ast.Import):
|
|
names.update(alias.name.split(".")[0] for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
assert node.level == 0, "no relative import: this module imports nothing in the package"
|
|
if node.module:
|
|
names.add(node.module.split(".")[0])
|
|
assert "logging" not in names
|
|
assert "cryptography" in names, "the one third-party dependency this phase adds"
|
|
|
|
|
|
def test_the_only_raise_is_the_fixed_text_refusal() -> None:
|
|
"""Every raise site is `RuntimeError(CORRUPT_STORE_MESSAGE)`: no f-string, no interpolated value.
|
|
|
|
The row that failed to parse may be a private key, and a message quoting even a prefix of it
|
|
would go into the startup output and wherever the app prints failures. Enumerating the raise
|
|
sites is what makes the *next* one somebody adds a test failure instead of a leak.
|
|
"""
|
|
raised = [node for node in ast.walk(_tree()) if isinstance(node, ast.Raise)]
|
|
assert raised, "the fail-closed refusal exists"
|
|
for node in raised:
|
|
assert isinstance(node.exc, ast.Call) and isinstance(node.exc.func, ast.Name)
|
|
assert node.exc.func.id == "RuntimeError"
|
|
assert len(node.exc.args) == 1
|
|
message = node.exc.args[0]
|
|
assert isinstance(message, ast.Name) and message.id == "CORRUPT_STORE_MESSAGE", (
|
|
"the refusal is the module-level literal, never a built string")
|
|
assert node.cause is None or isinstance(node.cause, ast.Name), "no interpolated context"
|
|
assert not any(isinstance(child, ast.JoinedStr) for child in ast.walk(node)), (
|
|
"a raise may not interpolate anything")
|
|
# ...and the literal behind that name is a literal, not something assembled at import time.
|
|
assigned = [stmt for stmt in _tree().body if isinstance(stmt, ast.Assign)
|
|
and any(isinstance(t, ast.Name) and t.id == "CORRUPT_STORE_MESSAGE"
|
|
for t in stmt.targets)]
|
|
assert len(assigned) == 1 and isinstance(assigned[0].value, ast.Constant)
|
|
assert isinstance(assigned[0].value.value, str)
|
|
|
|
|
|
# --- first boot: generate once, then never again ---------------------------------------------- #
|
|
|
|
|
|
def test_the_first_boot_persists_exactly_the_five_cert_rows() -> None:
|
|
store, state = boot()
|
|
|
|
assert sorted(store.rows) == sorted(certs.META_KEYS)
|
|
assert state.root_cert == store.rows[certs.META_ROOT_CERT]
|
|
assert state.client_key == store.rows[certs.META_CLIENT_KEY]
|
|
assert state.agents == ()
|
|
assert json.loads(store.rows[certs.META_AGENTS]) == {}, "the empty fleet is a row too"
|
|
# PEM text, not DER and not a path: `sqlite3 … SELECT value` shows a certificate.
|
|
assert store.rows[certs.META_ROOT_CERT].startswith("-----BEGIN CERTIFICATE-----")
|
|
assert store.rows[certs.META_CLIENT_KEY].startswith("-----BEGIN PRIVATE KEY-----")
|
|
|
|
|
|
def test_a_second_boot_changes_no_byte_of_any_stored_certificate() -> None:
|
|
"""The completion criterion for the task, and the one that makes a redeploy survivable.
|
|
|
|
A root CA regenerated per start would untrust every agent bundle in the fleet, so this is a
|
|
read that only fills gaps. The later `now` is part of the test: regenerating would also move
|
|
the expiry, and a state that merely *looks* current is worse than one that refuses.
|
|
"""
|
|
store, first = boot()
|
|
before = dict(store.rows)
|
|
|
|
second = certs.ensure_certs(store, now=NOW + 3_600)
|
|
|
|
assert store.rows == before, "no row moved, including the root key"
|
|
assert (second.root_cert, second.root_key) == (first.root_cert, first.root_key)
|
|
assert (second.client_cert, second.client_key) == (first.client_cert, first.client_key)
|
|
assert (second.root_serial, second.client_serial) == (first.root_serial, first.client_serial)
|
|
assert second.root_not_after == first.root_not_after
|
|
|
|
|
|
def test_a_missing_client_pair_is_generated_under_the_stored_root() -> None:
|
|
"""A crash between two `set_meta` calls leaves half a credential; the fix is the other half."""
|
|
store, first = boot()
|
|
del store.rows[certs.META_CLIENT_KEY]
|
|
del store.rows[certs.META_CLIENT_CERT]
|
|
|
|
second = certs.ensure_certs(store, now=NOW)
|
|
|
|
assert second.root_cert == first.root_cert, "the root is never regenerated to match a leaf"
|
|
assert second.client_cert != first.client_cert
|
|
cert_of(second.client_cert).verify_directly_issued_by(root_of(second))
|
|
|
|
|
|
def test_a_new_root_takes_the_client_certificate_with_it() -> None:
|
|
"""Keeping a leaf signed by a dead root would hand the process a credential nothing trusts."""
|
|
store, first = boot()
|
|
del store.rows[certs.META_ROOT_KEY]
|
|
del store.rows[certs.META_ROOT_CERT]
|
|
|
|
second = certs.ensure_certs(store, now=NOW)
|
|
|
|
assert second.root_serial != first.root_serial
|
|
assert second.client_cert != first.client_cert
|
|
cert_of(second.client_cert).verify_directly_issued_by(root_of(second))
|
|
with pytest.raises(InvalidSignature):
|
|
cert_of(first.client_cert).verify_directly_issued_by(root_of(second))
|
|
|
|
|
|
def test_a_row_of_whitespace_is_an_absent_row() -> None:
|
|
store, state = boot(rows={certs.META_ROOT_CERT: " \n "})
|
|
assert state.root_cert.startswith("-----BEGIN CERTIFICATE-----")
|
|
assert cert_of(state.root_cert).serial_number == state.root_serial
|
|
assert store.rows[certs.META_ROOT_CERT] == state.root_cert
|
|
|
|
|
|
# --- the shapes ------------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_the_root_is_self_signed_and_can_only_sign() -> None:
|
|
_store, state = boot()
|
|
root = root_of(state)
|
|
|
|
assert root.subject == root.issuer
|
|
assert cn(root) == certs.ROOT_CN
|
|
assert root.extensions.get_extension_for_class(x509.BasicConstraints).value.ca is True
|
|
usage = root.extensions.get_extension_for_class(x509.KeyUsage).value
|
|
assert usage.key_cert_sign is True and usage.crl_sign is True
|
|
assert usage.digital_signature is False, "a CA that signs data is a CA being used as a leaf"
|
|
assert root.not_valid_after_utc.timestamp() == NOW + certs.ROOT_LIFETIME.total_seconds()
|
|
assert certs.ROOT_LIFETIME.days == 3650 and certs.LEAF_LIFETIME.days == 365
|
|
assert state.root_not_after == root.not_valid_after_utc.timestamp()
|
|
|
|
|
|
def test_every_certificate_carries_the_identifiers_a_strict_verifier_requires() -> None:
|
|
"""AKI and SKI are not decoration: OpenSSL refuses a chain without them in strict mode.
|
|
|
|
Python's `ssl.create_default_context()` (which is what the dashboard's own https dial builds,
|
|
and what `curl --cacert`-style clients reach for) turns `X509_STRICT` on, and a strict
|
|
verification of a leaf with no AuthorityKeyIdentifier fails with
|
|
`CERTIFICATE_VERIFY_FAILED: Missing Authority Key Identifier` — every TLS agent would read
|
|
red, with an error that names neither the extension nor the module that left it out. The
|
|
agent's own server-side check does not use strict mode, which is why the TLS tests pass
|
|
without this and why it is pinned here instead.
|
|
"""
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
entry = state.agent("deskwork")
|
|
assert entry is not None
|
|
root = root_of(state)
|
|
|
|
root_ski = root.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
|
|
assert root_ski.digest, "a CA whose leaves cannot name it cannot be chained to strictly"
|
|
|
|
for label, cert in (("client", cert_of(state.client_cert)),
|
|
("agent", cert_of(bundle_pems(entry.bundle)["cert"]))):
|
|
identifier = cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
|
|
authority = cert.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier)
|
|
assert authority.value.key_identifier == root_ski.digest, f"{label}: AKI must name our root"
|
|
assert not identifier.critical and not authority.critical, (
|
|
"RFC 5280: the identifiers are non-critical, so a verifier that ignores them "
|
|
"still validates the signature")
|
|
|
|
# and a strict client context accepts what that produced, which is the claim the dial makes
|
|
context = ssl.create_default_context(cadata=state.root_cert)
|
|
assert context.verify_flags & ssl.VERIFY_X509_STRICT, "this is the mode being satisfied"
|
|
|
|
|
|
def _legacy_authority() -> FakeMeta:
|
|
"""The meta rows a pre-identifier `certs.py` left in a database: a root **with no SKI**.
|
|
|
|
This is the shape phase 24 task 03 found on a live store. The CA row was minted before
|
|
`SubjectKeyIdentifier` landed, and "a stored row always wins" means no later start re-mints
|
|
it, so every leaf signed afterwards carries an AKI naming an identifier its issuer does not
|
|
have. Written by hand here rather than by an old revision of the module, because the point is
|
|
to read what a database can hold, not what this module would choose to write.
|
|
|
|
The real clock, for the same reason the handshake test below uses it: `ssl` judges the
|
|
validity window itself, and the fixed `NOW` would read as an expired certificate.
|
|
"""
|
|
def pem_key(key: Any) -> str:
|
|
return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8,
|
|
NoEncryption()).decode("ascii")
|
|
|
|
def pem_cert(cert: x509.Certificate) -> str:
|
|
return cert.public_bytes(Encoding.PEM).decode("ascii")
|
|
|
|
def subject(name: str) -> x509.Name:
|
|
return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
|
|
|
|
moment = datetime.now(tz=UTC)
|
|
root_key = ec.generate_private_key(ec.SECP256R1())
|
|
root = (x509.CertificateBuilder().subject_name(subject(certs.ROOT_CN))
|
|
.issuer_name(subject(certs.ROOT_CN))
|
|
.public_key(root_key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(moment - timedelta(days=1))
|
|
.not_valid_after(moment + certs.ROOT_LIFETIME)
|
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
.sign(root_key, hashes.SHA256()))
|
|
client_key = ec.generate_private_key(ec.SECP256R1())
|
|
client = (x509.CertificateBuilder().subject_name(subject(certs.CLIENT_CN))
|
|
.issuer_name(subject(certs.ROOT_CN))
|
|
.public_key(client_key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(moment - timedelta(days=1))
|
|
.not_valid_after(moment + certs.LEAF_LIFETIME)
|
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
|
.sign(root_key, hashes.SHA256()))
|
|
return FakeMeta({certs.META_ROOT_KEY: pem_key(root_key),
|
|
certs.META_ROOT_CERT: pem_cert(root),
|
|
certs.META_CLIENT_KEY: pem_key(client_key),
|
|
certs.META_CLIENT_CERT: pem_cert(client),
|
|
certs.META_AGENTS: "{}"})
|
|
|
|
|
|
def _strict_dial_error(root_pem: str, leaf_pem: str, leaf_key_pem: str,
|
|
tmp_path: Path) -> str | None:
|
|
"""Serve `leaf_pem` and connect with the context the collector builds.
|
|
|
|
Returns `None` when the handshake completes, or the verification error's own sentence.
|
|
`create_default_context` is the whole point: it is what `collectors.AgentTls.ssl_context`
|
|
calls, so this is the dial the fleet actually makes and not a laxer stand-in.
|
|
"""
|
|
here = tmp_path / "strict"
|
|
here.mkdir(parents=True, exist_ok=True)
|
|
(here / "leaf.pem").write_text(leaf_pem)
|
|
(here / "leaf.key").write_text(leaf_key_pem)
|
|
(here / "ca.pem").write_text(root_pem)
|
|
|
|
server = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
server.load_cert_chain(str(here / "leaf.pem"), str(here / "leaf.key"))
|
|
client = ssl.create_default_context(cafile=str(here / "ca.pem"))
|
|
|
|
listener = socket.socket()
|
|
listener.bind(("127.0.0.1", 0))
|
|
listener.listen(1)
|
|
listener.settimeout(15)
|
|
port = listener.getsockname()[1]
|
|
|
|
def serve() -> None:
|
|
try:
|
|
raw, _peer = listener.accept()
|
|
except OSError:
|
|
return
|
|
try:
|
|
with server.wrap_socket(raw, server_side=True) as tls:
|
|
tls.recv(1)
|
|
except OSError:
|
|
pass # the server's half of a handshake the client refused
|
|
|
|
thread = threading.Thread(target=serve, daemon=True)
|
|
thread.start()
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=15) as raw, \
|
|
client.wrap_socket(raw, server_hostname="localhost") as tls:
|
|
tls.sendall(b"x")
|
|
return None
|
|
except ssl.SSLCertVerificationError as exc:
|
|
return str(exc)
|
|
finally:
|
|
listener.close()
|
|
thread.join(timeout=15)
|
|
|
|
|
|
def test_a_stored_root_without_an_identifier_still_boots_and_root_rotate_repairs_it(
|
|
tmp_path: Path) -> None:
|
|
"""A CA older than the identifier fix is a rotatable CA, not a bricked dashboard.
|
|
|
|
Three claims, all of them learned on 2026-10-21 while moving the fleet onto installed bundles:
|
|
|
|
* `ssl.create_default_context()` — strict mode — refuses a chain whose **CA** carries no
|
|
Subject Key Identifier, so every leaf such a root signs is rejected by the dashboard's own
|
|
dial. The live store hit exactly this: its `certs.root.cert` row predated the extension, and
|
|
a leaf minted afterwards (with its own SKI and a correct AKI) still failed the handshake
|
|
with `CERTIFICATE_VERIFY_FAILED: Missing Subject Key Identifier`.
|
|
* Nothing may fail closed over it. `read_state` reads those rows because `/certs` is the page
|
|
that fixes the problem — a startup that refused this table would leave the operator no
|
|
**Rotate root** button, and `DELETE FROM meta` is the last resort rather than the first.
|
|
* `rotate_root` is therefore the repair, and it has to actually repair the chain rather than
|
|
only bump a revision: a new root with its identifiers, a new client identity, every agent
|
|
re-issued under it, and the strict dial accepting the result.
|
|
"""
|
|
store = _legacy_authority()
|
|
state = certs.read_state(store)
|
|
with pytest.raises(x509.ExtensionNotFound):
|
|
root_of(state).extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
|
|
|
|
# A leaf issued *now*, with every identifier current `certs.py` adds, is still unverifiable:
|
|
# the gap is in the issuer, and no care at signing time reaches it.
|
|
state = issue(store, state, "localhost", ["localhost", "127.0.0.1"])
|
|
before = bundle_pems(entry_of(state, "localhost").bundle)
|
|
refused = _strict_dial_error(state.root_cert, before["cert"], before["key"], tmp_path)
|
|
assert refused is not None, (
|
|
"a strict client accepted a chain from a CA with no Subject Key Identifier, so this "
|
|
"OpenSSL is not the strict mode the fleet's dial runs in and the pin below means nothing")
|
|
assert "certificate verify failed" in refused.lower()
|
|
|
|
rotated = certs.rotate_root(store, state)
|
|
entry = entry_of(rotated, "localhost")
|
|
assert entry.bundle_rev == 2, "every host is re-issued under the new root, one bump each"
|
|
assert cert_of(rotated.root_cert).extensions.get_extension_for_class(
|
|
x509.SubjectKeyIdentifier), "the rotated root carries the identifier its leaves name"
|
|
after = bundle_pems(entry.bundle)
|
|
accepted = _strict_dial_error(rotated.root_cert, after["cert"], after["key"], tmp_path)
|
|
assert accepted is None, f"rotating the root must make the chain dialable: {accepted}"
|
|
|
|
|
|
def test_the_client_certificate_chains_to_the_root_and_is_a_client_identity() -> None:
|
|
_store, state = boot()
|
|
client = cert_of(state.client_cert)
|
|
assert client.verify_directly_issued_by(root_of(state)) is None
|
|
|
|
assert cn(client) == certs.CLIENT_CN
|
|
assert eku_of(client) == [x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH.dotted_string], (
|
|
"an agent verifies a client certificate against the clientAuth purpose")
|
|
assert client.extensions.get_extension_for_class(x509.BasicConstraints).value.ca is False
|
|
assert client.not_valid_after_utc.timestamp() == NOW + certs.LEAF_LIFETIME.total_seconds()
|
|
assert san_names(client) == [certs.CLIENT_CN]
|
|
assert state.client_serial == client.serial_number
|
|
assert state.client_not_after == client.not_valid_after_utc.timestamp()
|
|
|
|
|
|
def test_an_agent_certificate_chains_to_the_root_names_the_host_and_holds_the_san() -> None:
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51", "deskwork.reeselink.com"])
|
|
entry = state.agent("deskwork")
|
|
assert entry is not None
|
|
|
|
agent = cert_of(bundle_pems(entry.bundle)["cert"])
|
|
agent.verify_directly_issued_by(root_of(state))
|
|
assert cn(agent) == "deskwork"
|
|
assert san_names(agent) == ["10.1.0.51", "deskwork.reeselink.com", "deskwork"]
|
|
assert eku_of(agent) == [x509.oid.ExtendedKeyUsageOID.SERVER_AUTH.dotted_string]
|
|
assert agent.serial_number == entry.serial
|
|
assert agent.not_valid_after_utc.timestamp() == entry.not_after == (
|
|
NOW + certs.LEAF_LIFETIME.total_seconds())
|
|
# The key in the bundle is the key the certificate was issued for. An agent that loads a
|
|
# mismatched pair fails the handshake with an error about nothing it can see.
|
|
assert public_id(key_of(bundle_pems(entry.bundle)["key"]).public_key()) == public_id(agent.public_key())
|
|
|
|
|
|
def test_the_san_holds_ip_addresses_as_ips_and_names_as_dns() -> None:
|
|
"""`10.1.0.126` has to be an iPAddress SAN or hostname verification of an IP dial fails."""
|
|
store, state = boot()
|
|
state = issue(store, state, "gateway", ["10.1.1.27"])
|
|
entry = state.agent("gateway")
|
|
assert entry is not None
|
|
general = cert_of(bundle_pems(entry.bundle)["cert"]).extensions.get_extension_for_class(
|
|
x509.SubjectAlternativeName).value
|
|
assert {type(item).__name__ for item in general} == {"IPAddress", "DNSName"}
|
|
|
|
|
|
def test_the_host_name_is_always_in_the_san_and_nothing_is_listed_twice() -> None:
|
|
"""A caller that passes no address still gets a verifiable cert, and a duplicate is one name."""
|
|
store, state = boot()
|
|
state = issue(store, state, "driveripper", ["driveripper", " driveripper ", ""])
|
|
entry = state.agent("driveripper")
|
|
assert entry is not None
|
|
assert entry.san == ("driveripper",)
|
|
assert san_names(cert_of(bundle_pems(entry.bundle)["cert"])) == ["driveripper"]
|
|
|
|
|
|
# --- issue, rotate, revoke ---------------------------------------------------------------------- #
|
|
|
|
|
|
def test_a_first_issue_is_revision_one_active_and_one_row() -> None:
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
entry = state.agent("deskwork")
|
|
assert entry is not None
|
|
|
|
assert entry.status == certs.ACTIVE
|
|
assert entry.bundle_rev == 1
|
|
assert entry.serial > 0
|
|
assert entry.not_after == NOW + certs.LEAF_LIFETIME.total_seconds()
|
|
assert rev_of(entry.bundle) == 1
|
|
|
|
stored = json.loads(store.rows[certs.META_AGENTS])
|
|
assert list(stored) == ["deskwork"]
|
|
assert sorted(stored["deskwork"]) == ["bundle", "bundle_rev", "not_after", "san", "serial",
|
|
"status"]
|
|
assert stored["deskwork"]["bundle"] == entry.bundle, "the agent holds exactly what the row holds"
|
|
|
|
|
|
def test_a_reissue_bumps_the_revision_and_replaces_the_serial_and_the_bundle() -> None:
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
first = state.agent("deskwork")
|
|
assert first is not None
|
|
|
|
state = rotate(store, state, "deskwork", ["10.1.0.51"])
|
|
second = state.agent("deskwork")
|
|
assert second is not None
|
|
|
|
assert second.bundle_rev == first.bundle_rev + 1
|
|
assert second.serial != first.serial
|
|
assert second.bundle != first.bundle
|
|
assert rev_of(second.bundle) == second.bundle_rev
|
|
assert key_of(bundle_pems(second.bundle)["key"]) != key_of(bundle_pems(first.bundle)["key"]), (
|
|
"rotation replaces the key too, or the stolen one still works")
|
|
assert len(state.agents) == 1, "an issue is not an append"
|
|
|
|
|
|
def test_issue_and_reissue_are_one_operation_under_two_names() -> None:
|
|
"""Rotating a host with nothing issued yet is legal and yields revision 1 (the task's wording)."""
|
|
store, state = boot()
|
|
rotated = rotate(store, state, "deskwork", ["10.1.0.51"])
|
|
issued = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
|
|
assert entry_of(rotated, "deskwork").bundle_rev == 1
|
|
assert entry_of(issued, "deskwork").bundle_rev == 1
|
|
assert entry_of(rotated, "deskwork").status == certs.ACTIVE
|
|
assert entry_of(issued, "deskwork").status == certs.ACTIVE
|
|
|
|
|
|
def test_revoking_puts_the_serial_on_every_bundles_denylist() -> None:
|
|
"""The list lives inside each bundle, so revoking one host rewrites them all — that is the point."""
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
state = issue(store, state, "gateway", ["10.1.1.27"])
|
|
gateway = state.agent("gateway")
|
|
deskwork = state.agent("deskwork")
|
|
assert gateway is not None and deskwork is not None
|
|
|
|
state = revoke(store, state, "gateway")
|
|
|
|
assert entry_of(state, "gateway").status == certs.REVOKED
|
|
assert entry_of(state, "gateway").serial == gateway.serial, "the serial stays the denylisted one"
|
|
assert revoked_lines(entry_of(state, "gateway").bundle) == [gateway.serial]
|
|
assert revoked_lines(entry_of(state, "deskwork").bundle) == [gateway.serial], (
|
|
"an agent that cannot see the revoked serial keeps accepting that client certificate")
|
|
assert entry_of(state, "deskwork").bundle_rev == deskwork.bundle_rev, (
|
|
"a peer being revoked is not a new identity for this host")
|
|
assert entry_of(state, "deskwork").status == certs.ACTIVE
|
|
assert [entry.name for entry in certs.read_agents(store)] == ["deskwork", "gateway"]
|
|
|
|
|
|
def test_revoking_a_host_with_no_certificate_still_records_the_revocation() -> None:
|
|
"""What a revoke *does* is stop the dashboard dialing; never-issued must not be a no-op."""
|
|
store, state = boot()
|
|
state = revoke(store, state, "deskwork")
|
|
entry = state.agent("deskwork")
|
|
|
|
assert entry is not None
|
|
assert entry.status == certs.REVOKED
|
|
assert entry.bundle_rev == 1
|
|
assert revoked_lines(entry.bundle) == [entry.serial]
|
|
assert certs.agent_status(store, "deskwork") == certs.REVOKED
|
|
|
|
|
|
def test_a_reissue_after_a_revoke_gives_a_serial_that_is_not_listed() -> None:
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
state = issue(store, state, "gateway", ["10.1.1.27"])
|
|
revoked_serial = entry_of(state, "gateway").serial
|
|
state = revoke(store, state, "gateway")
|
|
|
|
state = rotate(store, state, "gateway", ["10.1.1.27"])
|
|
|
|
fresh = state.agent("gateway")
|
|
assert fresh is not None and fresh.status == certs.ACTIVE
|
|
assert fresh.serial != revoked_serial
|
|
assert revoked_lines(fresh.bundle) == [], "the fix for a revoked identity is a new one"
|
|
assert revoked_lines(entry_of(state, "deskwork").bundle) == []
|
|
assert certs.agent_status(store, "gateway") == certs.ACTIVE
|
|
|
|
|
|
def test_two_revocations_are_both_listed_and_in_one_order() -> None:
|
|
store, state = boot()
|
|
for name in ("deskwork", "gateway", "driveripper"):
|
|
state = issue(store, state, name, [f"10.1.0.{len(name)}"])
|
|
state = revoke(store, state, "deskwork")
|
|
state = revoke(store, state, "driveripper")
|
|
|
|
expected = sorted([entry_of(state, "deskwork").serial, entry_of(state, "driveripper").serial])
|
|
for name in ("deskwork", "gateway", "driveripper"):
|
|
assert revoked_lines(entry_of(state, name).bundle) == expected, "one order, in every bundle"
|
|
assert entry_of(state, "gateway").status == certs.ACTIVE
|
|
|
|
|
|
def test_agent_status_names_only_what_the_operator_revoked() -> None:
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
state = revoke(store, state, "deskwork")
|
|
|
|
assert certs.agent_status(store, "deskwork") == certs.REVOKED
|
|
assert certs.agent_status(store, "gateway") == certs.ACTIVE
|
|
assert certs.agent_status(FakeMeta(), "anything") == certs.ACTIVE, (
|
|
"no row is not a revocation: the safe answer is to keep dialing")
|
|
|
|
|
|
def test_read_agents_is_a_fresh_view_of_the_table() -> None:
|
|
"""Task 03 re-reads this each poll; a cached view would delay a revocation by a restart."""
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
assert [entry.name for entry in certs.read_agents(store)] == ["deskwork"]
|
|
snapshot = state
|
|
|
|
state = revoke(store, state, "deskwork")
|
|
|
|
assert entry_of(snapshot, "deskwork").status == certs.ACTIVE, "a returned state is a snapshot"
|
|
assert certs.read_agents(store)[0].status == certs.REVOKED
|
|
|
|
|
|
def test_read_agents_sorts_by_name_so_one_order_reaches_the_page() -> None:
|
|
store, state = boot()
|
|
for name in ("gateway", "deskwork", "driveripper"):
|
|
state = issue(store, state, name)
|
|
assert [entry.name for entry in certs.read_agents(store)] == [
|
|
"deskwork", "driveripper", "gateway"]
|
|
assert [entry.name for entry in state.agents] == ["deskwork", "driveripper", "gateway"]
|
|
|
|
|
|
def test_nothing_is_issued_on_a_read() -> None:
|
|
"""A `GET` that minted a certificate would make "did anyone rotate this?" unanswerable."""
|
|
store, state = boot()
|
|
before = dict(store.rows)
|
|
for _ in range(5):
|
|
certs.ensure_certs(store, now=NOW)
|
|
certs.read_state(store)
|
|
certs.read_agents(store)
|
|
certs.agent_status(store, "deskwork")
|
|
assert store.rows == before
|
|
assert state.agents == ()
|
|
|
|
|
|
def test_read_state_is_the_stored_authority_rather_than_the_bootstrapped_one() -> None:
|
|
"""`read_state` answers "what does the table hold now?", which is what `GET /api/certs` prints.
|
|
|
|
After a root rotation the process still holds the state it started with, so a page reading that
|
|
object would show a CA the operator has just replaced. The read goes to the rows, which is the
|
|
same reason task 03 reads `agent_status` per poll instead of caching it at startup.
|
|
"""
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
stale_root = state.root_serial
|
|
|
|
rotated = certs.rotate_root(store, state, now=NOW)
|
|
|
|
fresh = certs.read_state(store)
|
|
assert (fresh.root_serial, fresh.client_serial) == (rotated.root_serial,
|
|
rotated.client_serial)
|
|
assert fresh.root_serial != stale_root, "the old authority is not what the read answers"
|
|
assert fresh.root_cert == store.rows[certs.META_ROOT_CERT], "byte for byte, as stored"
|
|
assert [entry.name for entry in fresh.agents] == [entry.name for entry in rotated.agents]
|
|
|
|
|
|
def test_read_state_refuses_a_missing_row_instead_of_minting_one() -> None:
|
|
"""The one behaviour that separates this read from `ensure_certs`, pinned from both sides.
|
|
|
|
A GET that quietly regenerated a deleted row would both write on a read and replace the CA with
|
|
one nothing in the fleet trusts — the failure `ensure_certs` exists to bootstrap at startup and
|
|
the failure a request must never cause. `ensure_certs` on the same table generates; `read_state`
|
|
refuses, and the message is the fixed reset text that names the rows.
|
|
"""
|
|
store, _state = boot()
|
|
store.rows.pop(certs.META_CLIENT_CERT)
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.read_state(store)
|
|
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
assert store.rows.get(certs.META_CLIENT_CERT) is None, "the refusal wrote nothing back"
|
|
|
|
regenerated = certs.ensure_certs(store, now=NOW) # the startup read does mint it
|
|
assert regenerated.client_cert
|
|
|
|
|
|
@pytest.mark.parametrize("key", [certs.META_ROOT_KEY, certs.META_ROOT_CERT,
|
|
certs.META_CLIENT_KEY, certs.META_CLIENT_CERT])
|
|
def test_read_state_validates_the_rows_it_reports(key: str) -> None:
|
|
"""A page listing a corrupt authority would be a calmer way to hide the outage."""
|
|
store, _state = boot()
|
|
store.rows[key] = "not a PEM document"
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.read_state(store)
|
|
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
def test_read_state_refuses_a_key_that_is_not_the_one_its_certificate_certifies() -> None:
|
|
"""A row copied over from another row is corruption with a plausible look to it.
|
|
|
|
The two halves of a pair are separate `meta` rows, so this is exactly what a hand-edit produces,
|
|
and a process that started with a mismatched pair would sign leaves with a key nothing verifies.
|
|
"""
|
|
store, _state = boot()
|
|
store.rows[certs.META_CLIENT_KEY] = store.rows[certs.META_ROOT_KEY]
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.read_state(store)
|
|
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
def test_read_state_refuses_a_leaf_the_stored_root_did_not_sign() -> None:
|
|
"""A crash between the root write and the client write is the corrupt state, not a half-state."""
|
|
store, _state = boot()
|
|
other, _other_state = boot()
|
|
store.rows[certs.META_CLIENT_CERT] = other.rows[certs.META_CLIENT_CERT]
|
|
store.rows[certs.META_CLIENT_KEY] = other.rows[certs.META_CLIENT_KEY]
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.read_state(store)
|
|
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
# --- root rotation ------------------------------------------------------------------------------ #
|
|
|
|
|
|
def test_rotating_the_root_reissues_every_agent_and_resets_the_denylist() -> None:
|
|
store, state = boot()
|
|
for name in ("deskwork", "gateway"):
|
|
state = issue(store, state, name, [f"10.1.0.{len(name)}"])
|
|
state = revoke(store, state, "gateway")
|
|
old_root = root_of(state)
|
|
old_client_serial = state.client_serial
|
|
before = {entry.name: entry for entry in state.agents}
|
|
|
|
state = certs.rotate_root(store, state, now=NOW)
|
|
|
|
new_root = root_of(state)
|
|
assert new_root.serial_number != old_root.serial_number
|
|
assert state.client_serial != old_client_serial
|
|
cert_of(state.client_cert).verify_directly_issued_by(new_root)
|
|
|
|
for name, previous in before.items():
|
|
entry = state.agent(name)
|
|
assert entry is not None
|
|
assert entry.status == certs.ACTIVE, "every identity is replaced, so nothing stays revoked"
|
|
assert entry.bundle_rev == previous.bundle_rev + 1
|
|
assert entry.serial != previous.serial
|
|
assert entry.san == previous.san, "rotation must not change the names the dashboard dials"
|
|
cert_of(bundle_pems(entry.bundle)["cert"]).verify_directly_issued_by(new_root)
|
|
assert bundle_pems(entry.bundle)["root"] == state.root_cert
|
|
assert revoked_lines(entry.bundle) == [], "the denylist belongs to one generation"
|
|
assert json.loads(store.rows[certs.META_AGENTS])["gateway"]["bundle_rev"] == (
|
|
before["gateway"].bundle_rev + 1)
|
|
|
|
|
|
def test_no_stored_value_still_carries_the_old_generation() -> None:
|
|
"""A rotated root has to be *gone*: a stale key or cert left in a row is the leak and the bug."""
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
old_root_key, old_root_cert, old_client_key = state.root_key, state.root_cert, state.client_key
|
|
|
|
state = certs.rotate_root(store, state, now=NOW)
|
|
|
|
for key, value in store.rows.items():
|
|
assert old_root_key not in value, key
|
|
assert old_root_cert not in value, key
|
|
assert old_client_key not in value, key
|
|
assert state.root_key != old_root_key
|
|
assert root_of(state).serial_number != cert_of(old_root_cert).serial_number
|
|
entry = state.agent("deskwork")
|
|
assert entry is not None and old_root_cert not in entry.bundle
|
|
|
|
|
|
def test_rotating_the_root_with_no_agents_yet_still_leaves_a_working_credential() -> None:
|
|
store, state = boot()
|
|
state = certs.rotate_root(store, state, now=NOW)
|
|
assert state.agents == ()
|
|
cert_of(state.client_cert).verify_directly_issued_by(root_of(state))
|
|
assert sorted(store.rows) == sorted(certs.META_KEYS)
|
|
|
|
|
|
# --- the bundle --------------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_the_bundle_is_metadata_then_agent_cert_then_agent_key_then_root_cert() -> None:
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
entry = state.agent("deskwork")
|
|
assert entry is not None
|
|
bundle = entry.bundle
|
|
|
|
assert bundle.startswith(f"{certs.BUNDLE_REV_LINE}1\n")
|
|
assert "# REVOKED" not in bundle, "nothing is revoked on a fresh root with nothing revoked"
|
|
assert [block.split("-----")[1] for block in blocks(bundle)] == [
|
|
"BEGIN CERTIFICATE", "BEGIN PRIVATE KEY", "BEGIN CERTIFICATE"]
|
|
assert bundle_pems(bundle)["root"] == state.root_cert, "the trust anchor is the stored root"
|
|
assert bundle_pems(bundle)["cert"] == cert_of(
|
|
bundle_pems(bundle)["cert"]).public_bytes(Encoding.PEM).decode("ascii")
|
|
|
|
|
|
def test_the_bundle_carries_the_agent_key_and_never_the_root_key() -> None:
|
|
"""The TODO's "root certificate installed with each exporter" is the root *cert*.
|
|
|
|
Asserted twice on purpose: by header count (one key block, so a second private key is visible)
|
|
and by parsing that key against both keypairs, so a swap that kept the count honest still
|
|
fails here.
|
|
"""
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
entry = state.agent("deskwork")
|
|
assert entry is not None
|
|
bundle = entry.bundle
|
|
|
|
assert bundle.count("-----BEGIN PRIVATE KEY-----") == 1
|
|
assert state.root_key not in bundle
|
|
in_bundle = key_of(bundle_pems(bundle)["key"])
|
|
agent_cert = cert_of(bundle_pems(bundle)["cert"])
|
|
assert public_id(in_bundle.public_key()) == public_id(agent_cert.public_key())
|
|
assert public_id(in_bundle.public_key()) != public_id(root_of(state).public_key())
|
|
|
|
|
|
def test_a_bundle_that_is_not_the_stored_shape_is_the_fixed_text_refusal() -> None:
|
|
"""A hand-edited or truncated row cannot be re-sealed — and re-sealing is what revoking does."""
|
|
store, state = boot()
|
|
state = issue(store, state, "deskwork", ["10.1.0.51"])
|
|
payload = json.loads(store.rows[certs.META_AGENTS])
|
|
payload["deskwork"]["bundle"] = "-----BEGIN CERTIFICATE-----\nbroken\n"
|
|
store.set_meta(certs.META_AGENTS, json.dumps(payload))
|
|
tampered = certs.ensure_certs(store, now=NOW)
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
revoke(store, tampered, "deskwork")
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
# --- fail closed on a corrupt row ---------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.parametrize("key", [certs.META_ROOT_KEY, certs.META_ROOT_CERT,
|
|
certs.META_CLIENT_KEY, certs.META_CLIENT_CERT])
|
|
def test_a_stored_pem_that_will_not_parse_refuses_to_start(key: str) -> None:
|
|
store, _state = boot()
|
|
store.rows[key] = "not a PEM document"
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.ensure_certs(store, now=NOW)
|
|
|
|
message = str(caught.value)
|
|
assert message == certs.CORRUPT_STORE_MESSAGE
|
|
for name in certs.META_KEYS:
|
|
assert name in message, "the operator is told which rows to delete"
|
|
|
|
|
|
def test_a_key_type_this_build_cannot_use_refuses_to_start() -> None:
|
|
"""Somebody hand-inserting an RSA key gets the reset message, not a stack trace at first dial."""
|
|
store, _state = boot()
|
|
rsa_pem = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_bytes(
|
|
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode("ascii")
|
|
store.rows[certs.META_CLIENT_KEY] = rsa_pem
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.ensure_certs(store, now=NOW)
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
def test_a_key_that_does_not_match_its_certificate_refuses_to_start() -> None:
|
|
store, _state = boot()
|
|
other = FakeMeta()
|
|
other_state = certs.ensure_certs(other, now=NOW)
|
|
store.rows[certs.META_CLIENT_KEY] = other_state.client_key
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.ensure_certs(store, now=NOW)
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
def test_a_client_certificate_the_stored_root_did_not_sign_refuses_to_start() -> None:
|
|
"""The half-finished root rotation: a new root beside an old leaf is a dashboard that reaches nothing."""
|
|
store, _state = boot()
|
|
other = FakeMeta()
|
|
other_state = certs.ensure_certs(other, now=NOW)
|
|
store.rows[certs.META_CLIENT_CERT] = other_state.client_cert
|
|
store.rows[certs.META_CLIENT_KEY] = other_state.client_key
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.ensure_certs(store, now=NOW)
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
def test_a_root_key_and_cert_from_two_generations_refuse_to_start() -> None:
|
|
store, _state = boot()
|
|
other = FakeMeta()
|
|
other_state = certs.ensure_certs(other, now=NOW)
|
|
store.rows[certs.META_ROOT_KEY] = other_state.root_key
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.ensure_certs(store, now=NOW)
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
@pytest.mark.parametrize("raw", [
|
|
"not json at all",
|
|
"[]",
|
|
'{"deskwork": "nope"}',
|
|
'{"deskwork": {"serial": 1, "not_after": 2, "status": "active", "bundle_rev": 1, '
|
|
'"bundle": "x"}}',
|
|
'{"deskwork": {"serial": 1, "not_after": 2, "status": "active", "bundle_rev": 1, '
|
|
'"bundle": "x", "san": "deskwork"}}',
|
|
'{"deskwork": {"serial": "abc", "not_after": 2, "status": "active", "bundle_rev": 1, '
|
|
'"bundle": "x", "san": []}}',
|
|
'{"deskwork": {"serial": 1, "not_after": 2, "status": "active", "bundle_rev": 1, '
|
|
'"bundle": "", "san": []}}',
|
|
])
|
|
def test_an_agent_table_that_is_not_the_stored_shape_refuses_to_start(raw: str) -> None:
|
|
"""Every one of these is what a partial write or a hand edit could leave behind."""
|
|
store, _state = boot()
|
|
store.rows[certs.META_AGENTS] = raw
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.ensure_certs(store, now=NOW)
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
|
|
|
|
def test_the_refusal_quotes_nothing_from_the_database() -> None:
|
|
"""invariant 6, against the value that matters most in this module: the root key."""
|
|
store, _state = boot()
|
|
secret = store.rows[certs.META_ROOT_KEY]
|
|
store.rows[certs.META_ROOT_CERT] = "truncated-----"
|
|
|
|
with pytest.raises(RuntimeError) as caught:
|
|
certs.ensure_certs(store, now=NOW)
|
|
|
|
message = str(caught.value)
|
|
body = "\n".join(secret.splitlines()[1:-1])
|
|
assert body not in message
|
|
assert body[:32] not in message
|
|
assert store.rows[certs.META_ROOT_KEY] == secret, "refusing must not rewrite the rows"
|
|
|
|
|
|
# --- durability through the real store -------------------------------------------------------------- #
|
|
|
|
|
|
def test_the_certificates_survive_a_reopen_of_the_real_database(tmp_path: Path) -> None:
|
|
"""The whole reason this lives in SQLite: a restart re-reads it and an agent install stays valid."""
|
|
path = tmp_path / "dashboards.sqlite3"
|
|
first_store = SampleStore(path)
|
|
try:
|
|
first = certs.ensure_certs(first_store, now=NOW)
|
|
issued = certs.issue_agent_cert(first_store, first, "deskwork", ["10.1.0.51"], now=NOW)
|
|
finally:
|
|
first_store.close()
|
|
|
|
reopened = SampleStore(path)
|
|
try:
|
|
second = certs.ensure_certs(reopened, now=NOW + 10)
|
|
finally:
|
|
reopened.close()
|
|
|
|
assert second.root_cert == first.root_cert
|
|
assert second.root_key == first.root_key
|
|
assert second.client_cert == first.client_cert
|
|
entry = second.agent("deskwork")
|
|
assert entry is not None
|
|
assert entry.bundle == entry_of(issued, "deskwork").bundle
|
|
assert entry.bundle_rev == 1
|
|
|
|
|
|
# --- on-disk material ------------------------------------------------------------------------------ #
|
|
|
|
|
|
def test_the_material_is_three_private_files_and_never_the_root_key(tmp_path: Path) -> None:
|
|
_store, state = boot()
|
|
material = certs.write_material(state, tmp_path)
|
|
try:
|
|
assert sorted(p.name for p in material.dir.iterdir()) == [
|
|
CLIENT_CERT_FILE, CLIENT_KEY_FILE, CA_FILE]
|
|
assert material.ca.read_text() == state.root_cert
|
|
assert material.client_cert.read_text() == state.client_cert
|
|
assert material.client_key.read_text() == state.client_key
|
|
assert (material.dir.stat().st_mode & 0o777) == 0o700
|
|
for path in (material.ca, material.client_cert, material.client_key):
|
|
assert (path.stat().st_mode & 0o777) == 0o600, str(path)
|
|
assert state.root_key not in "\n".join(p.read_text() for p in material.dir.iterdir())
|
|
assert material.state is state
|
|
finally:
|
|
certs.remove_material(material)
|
|
|
|
|
|
def test_the_material_defaults_to_the_environment_tmp_dir() -> None:
|
|
_store, state = boot()
|
|
material = certs.write_material(state)
|
|
try:
|
|
assert material.dir.name.startswith("dashboards-certs-")
|
|
assert material.dir.parent.resolve() == Path(tempfile.gettempdir()).resolve()
|
|
assert material.ca.exists()
|
|
finally:
|
|
certs.remove_material(material)
|
|
|
|
|
|
def test_removing_the_material_takes_the_directory_and_tolerates_none(tmp_path: Path) -> None:
|
|
_store, state = boot()
|
|
material = certs.write_material(state, tmp_path)
|
|
certs.remove_material(material)
|
|
assert not material.dir.exists()
|
|
certs.remove_material(None)
|
|
|
|
|
|
# --- the material works in a real TLS handshake ----------------------------------------------------- #
|
|
|
|
|
|
def test_the_material_and_a_bundle_complete_a_real_client_certificate_handshake(
|
|
tmp_path: Path) -> None:
|
|
"""The claim tasks 02 and 03 rest on, checked with `ssl` instead of with this module's parser.
|
|
|
|
A certificate `cryptography` wrote that `ssl` will not load is the worst bug available here:
|
|
every other test in this file would stay green while no agent in the fleet could handshake.
|
|
The server side runs with `CERT_OPTIONAL`, which is task 02's assumption that `/health` stays
|
|
probeable without a client certificate, so both answers are asserted: a peer certificate for
|
|
the dashboard, and none for a bare probe.
|
|
"""
|
|
# Real clock for this one test: `ssl` checks the validity window itself, so the fixed `NOW`
|
|
# this file mints everything else at would be "certificate has expired" by 2027.
|
|
store = FakeMeta()
|
|
state = certs.ensure_certs(store)
|
|
state = certs.issue_agent_cert(store, state, "localhost", ["127.0.0.1", "localhost"])
|
|
entry = state.agent("localhost")
|
|
assert entry is not None
|
|
pems = bundle_pems(entry.bundle)
|
|
(tmp_path / "agent-cert.pem").write_text(pems["cert"])
|
|
(tmp_path / "agent-key.pem").write_text(pems["key"])
|
|
(tmp_path / CA_FILE).write_text(state.root_cert)
|
|
base = tmp_path / "material"
|
|
base.mkdir()
|
|
material = certs.write_material(state, base)
|
|
try:
|
|
(tmp_path / CLIENT_CERT_FILE).write_text(material.client_cert.read_text())
|
|
(tmp_path / CLIENT_KEY_FILE).write_text(material.client_key.read_text())
|
|
|
|
server = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
server.load_cert_chain(str(tmp_path / "agent-cert.pem"), str(tmp_path / "agent-key.pem"))
|
|
server.load_verify_locations(str(tmp_path / CA_FILE))
|
|
server.verify_mode = ssl.CERT_OPTIONAL
|
|
|
|
with_cert = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
with_cert.load_verify_locations(str(tmp_path / CA_FILE))
|
|
with_cert.load_cert_chain(str(tmp_path / CLIENT_CERT_FILE), str(tmp_path / CLIENT_KEY_FILE))
|
|
without_cert = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
without_cert.load_verify_locations(str(tmp_path / CA_FILE))
|
|
|
|
seen = _handshake(server, [with_cert, without_cert], tmp_path)
|
|
finally:
|
|
certs.remove_material(material)
|
|
|
|
assert seen[0], "the dashboard's client certificate reached the agent"
|
|
assert any(name == "commonName" and value == certs.CLIENT_CN
|
|
for name, value in seen[0]["subject"][0])
|
|
# `getpeercert()` says "no certificate" as `{}` on some builds and `None` on others; the
|
|
# handler's check (task 02) is `if not cert`, which is what this pins rather than the empty
|
|
# value's exact shape.
|
|
assert not seen[1], "a probe with no client certificate still handshakes: the /health path"
|
|
|
|
|
|
def _handshake(server: ssl.SSLContext, clients: list[ssl.SSLContext],
|
|
tmp_path: Path) -> list[Any]:
|
|
"""Serve `server` on a loopback port and run one handshake per client context."""
|
|
assert len(clients) == 2
|
|
listener = socket.socket()
|
|
listener.bind(("127.0.0.1", 0))
|
|
listener.listen(2)
|
|
listener.settimeout(15)
|
|
port = listener.getsockname()[1]
|
|
seen: list[Any] = []
|
|
|
|
def serve() -> None:
|
|
for _ in clients:
|
|
try:
|
|
raw, _peer = listener.accept()
|
|
except OSError:
|
|
return
|
|
try:
|
|
with server.wrap_socket(raw, server_side=True) as tls:
|
|
seen.append(tls.getpeercert())
|
|
tls.sendall(b"ok")
|
|
except OSError as exc: # a refused handshake is itself an answer under test
|
|
seen.append(exc)
|
|
|
|
thread = threading.Thread(target=serve, daemon=True)
|
|
thread.start()
|
|
try:
|
|
for context in clients:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=15) as raw, \
|
|
context.wrap_socket(raw, server_hostname="localhost") as tls:
|
|
assert tls.recv(2) == b"ok"
|
|
finally:
|
|
listener.close()
|
|
thread.join(timeout=15)
|
|
return seen
|
|
|
|
|
|
# --- the app wiring (work item 3) ---------------------------------------------------------------- #
|
|
|
|
|
|
def settings_for(db: str) -> Settings:
|
|
return Settings(db_path=db, admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2)
|
|
|
|
|
|
def test_the_lifespan_builds_the_material_before_the_loops_start(tmp_path: Path) -> None:
|
|
"""`ctx.certs` is there for task 03 to dial with, in the injected-store branch too."""
|
|
db = str(tmp_path / "app.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(settings_for(db), store)
|
|
try:
|
|
with TestClient(app):
|
|
context = app.state.ctx
|
|
assert context.certs is not None
|
|
assert context.certs.ca.exists() and context.certs.client_cert.exists()
|
|
assert context.certs.client_key.exists()
|
|
directory = context.certs.dir
|
|
for key in certs.META_KEYS:
|
|
assert store.get_meta(key), f"{key} is durable, not only in memory"
|
|
assert context.auth is not None
|
|
assert auth.verify_password(ADMIN_PASSWORD, context.auth.verifier) is True
|
|
finally:
|
|
store.close()
|
|
assert not directory.exists(), "the private directory goes with the process"
|
|
|
|
|
|
def test_a_corrupt_stored_certificate_makes_the_server_refuse_to_start(tmp_path: Path) -> None:
|
|
"""Fail closed, before the loops: a dashboard with no credential cannot report anything truthfully."""
|
|
db = str(tmp_path / "corrupt.sqlite3")
|
|
store = SampleStore(db)
|
|
certs.ensure_certs(store, now=NOW)
|
|
store.set_meta(certs.META_ROOT_CERT, "the certificate that is not one")
|
|
app = create_app(settings_for(db), store)
|
|
try:
|
|
with pytest.raises(RuntimeError) as caught, TestClient(app):
|
|
pytest.fail("the lifespan yielded with unreadable certificates")
|
|
assert str(caught.value) == certs.CORRUPT_STORE_MESSAGE
|
|
assert not hasattr(app.state, "ctx"), "nothing is published, so nothing can serve"
|
|
finally:
|
|
store.close()
|