diff --git a/src/nlboot/verify.py b/src/nlboot/verify.py index 59cf2f3..f58ad0a 100644 --- a/src/nlboot/verify.py +++ b/src/nlboot/verify.py @@ -2,6 +2,7 @@ import json from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any from cryptography.exceptions import InvalidSignature @@ -10,33 +11,78 @@ FIPS_READY_ALGORITHM = "rsa-pss-sha256" FIPS_READY_PROFILE = "fips-140-3-compatible" +ACTIVE_KEY_STATUS = "active" class VerificationError(ValueError): """Raised when a signature or trusted-key check fails.""" +def _parse_time(value: str, *, field: str) -> datetime: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise VerificationError(f"{field} must be an ISO-8601 datetime") from exc + if parsed.tzinfo is None: + raise VerificationError(f"{field} must include timezone information") + return parsed.astimezone(timezone.utc) + + @dataclass(frozen=True) class TrustedKey: key_ref: str algorithm: str public_key_pem: str + status: str + not_before: datetime | None + not_after: datetime | None + revoked_at: datetime | None + revocation_reason: str | None @classmethod def from_dict(cls, data: dict[str, Any]) -> "TrustedKey": key_ref = data.get("key_ref") algorithm = data.get("algorithm") public_key_pem = data.get("public_key_pem") + status = data.get("status", ACTIVE_KEY_STATUS) if not isinstance(key_ref, str) or not key_ref: raise VerificationError("trusted key requires key_ref") if algorithm != FIPS_READY_ALGORITHM: raise VerificationError("trusted key must use rsa-pss-sha256") if not isinstance(public_key_pem, str) or "BEGIN PUBLIC KEY" not in public_key_pem: raise VerificationError("trusted key requires PEM public key") - return cls(key_ref=key_ref, algorithm=algorithm, public_key_pem=public_key_pem) + if status not in {"active", "retired", "revoked"}: + raise VerificationError("trusted key status must be active, retired, or revoked") + not_before_raw = data.get("not_before") + not_after_raw = data.get("not_after") + revoked_at_raw = data.get("revoked_at") + revocation_reason = data.get("revocation_reason") + if revocation_reason is not None and not isinstance(revocation_reason, str): + raise VerificationError("revocation_reason must be string when present") + return cls( + key_ref=key_ref, + algorithm=algorithm, + public_key_pem=public_key_pem, + status=status, + not_before=_parse_time(not_before_raw, field="not_before") if isinstance(not_before_raw, str) else None, + not_after=_parse_time(not_after_raw, field="not_after") if isinstance(not_after_raw, str) else None, + revoked_at=_parse_time(revoked_at_raw, field="revoked_at") if isinstance(revoked_at_raw, str) else None, + revocation_reason=revocation_reason, + ) + + def validate_lifecycle(self, *, now: datetime | None = None) -> None: + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if self.status == "revoked" or self.revoked_at is not None: + raise VerificationError(f"trusted key {self.key_ref!r} is revoked") + if self.status != ACTIVE_KEY_STATUS: + raise VerificationError(f"trusted key {self.key_ref!r} is not active") + if self.not_before and current < self.not_before: + raise VerificationError(f"trusted key {self.key_ref!r} is not active yet") + if self.not_after and current >= self.not_after: + raise VerificationError(f"trusted key {self.key_ref!r} is expired") -def load_trusted_keys(data: dict[str, Any]) -> dict[str, TrustedKey]: +def load_trusted_keys(data: dict[str, Any], *, now: datetime | None = None) -> dict[str, TrustedKey]: keys = data.get("keys") if not isinstance(keys, list): raise VerificationError("trusted key document requires keys array") @@ -45,6 +91,7 @@ def load_trusted_keys(data: dict[str, Any]) -> dict[str, TrustedKey]: if not isinstance(item, dict): raise VerificationError("trusted key entries must be objects") key = TrustedKey.from_dict(item) + key.validate_lifecycle(now=now) loaded[key.key_ref] = key return loaded diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..79b2b1e --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from nlboot.verify import VerificationError, load_trusted_keys + +PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArBfEOS4UzauNLkcJB/JY +UQy0qkPOyyj1Zm2dLd00KXxeoj6JnfUtqIsYz7lmiWOKQf4bJlpbl3acKkSSdIDv +o3h32zqNDBO8vlnX26ym7qBRhWd9BR6CZ5/+Qu/AYcMbbtQf5OYK65BBWEZQGDE1 +56ihXzaWoxZDAHt0FZpD8PgtCaCcXT5qmLYhk207cVdVpxJ9+knWisu2F6KPcgOh +WwBevbIFfv/QYac0LupV/bXpGFiNMVbfWgHIT1s4plFXRBtdJG4maoIc8B6ln2pT ++nDHozjCWDloI322WGabunZyZRrNzdg1eWM0Xk5XH1zeo+7hcxvjvUJeOVQtDs5v +twIDAQAB +-----END PUBLIC KEY----- +""" + + +def key_doc(**overrides: object) -> dict[str, object]: + key = { + "key_ref": "urn:srcos:key:sourceos-release-root", + "algorithm": "rsa-pss-sha256", + "public_key_pem": PUBLIC_KEY, + "status": "active", + "not_before": "2026-04-01T00:00:00Z", + "not_after": "2026-05-01T00:00:00Z", + } + key.update(overrides) + return {"keys": [key]} + + +def now() -> datetime: + return datetime(2026, 4, 26, 14, 35, tzinfo=timezone.utc) + + +def test_active_key_loads_inside_validity_window(): + keys = load_trusted_keys(key_doc(), now=now()) + assert "urn:srcos:key:sourceos-release-root" in keys + + +def test_future_key_rejected(): + with pytest.raises(VerificationError, match="not active yet"): + load_trusted_keys(key_doc(not_before="2026-04-27T00:00:00Z"), now=now()) + + +def test_expired_key_rejected(): + with pytest.raises(VerificationError, match="expired"): + load_trusted_keys(key_doc(not_after="2026-04-26T14:00:00Z"), now=now()) + + +def test_retired_key_rejected(): + with pytest.raises(VerificationError, match="not active"): + load_trusted_keys(key_doc(status="retired"), now=now()) + + +def test_revoked_key_rejected(): + with pytest.raises(VerificationError, match="revoked"): + load_trusted_keys( + key_doc( + status="revoked", + revoked_at="2026-04-20T00:00:00Z", + revocation_reason="compromised", + ), + now=now(), + )