Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions src/nlboot/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

from cryptography.exceptions import InvalidSignature
Expand All @@ -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")
Expand All @@ -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

Expand Down
67 changes: 67 additions & 0 deletions tests/test_verify.py
Original file line number Diff line number Diff line change
@@ -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(),
)
Loading