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
63 changes: 49 additions & 14 deletions src/nlboot/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,25 +82,16 @@ def validate_lifecycle(self, *, now: datetime | None = None) -> None:
raise VerificationError(f"trusted key {self.key_ref!r} is expired")


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")
loaded: dict[str, TrustedKey] = {}
for item in keys:
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


def canonical_payload(data: dict[str, Any]) -> bytes:
unsigned = {k: v for k, v in data.items() if k != "signature_hex"}
return json.dumps(unsigned, sort_keys=True, separators=(",", ":")).encode("utf-8")


def canonical_trust_bundle_payload(data: dict[str, Any]) -> bytes:
unsigned = {k: v for k, v in data.items() if k not in {"signature_hex", "signatures"}}
return json.dumps(unsigned, sort_keys=True, separators=(",", ":")).encode("utf-8")


def verify_rsa_pss_sha256(*, payload: bytes, signature_hex: str, trusted_key: TrustedKey) -> None:
try:
signature = bytes.fromhex(signature_hex)
Expand All @@ -120,3 +111,47 @@ def verify_rsa_pss_sha256(*, payload: bytes, signature_hex: str, trusted_key: Tr
)
except InvalidSignature as exc:
raise VerificationError("signature verification failed") from exc


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")
loaded: dict[str, TrustedKey] = {}
for item in keys:
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


def load_verified_trust_bundle(
data: dict[str, Any], *, root_keys: dict[str, TrustedKey], now: datetime | None = None
) -> dict[str, TrustedKey]:
bundle_id = data.get("bundle_id")
signer_ref = data.get("signer_ref")
signature_hex = data.get("signature_hex")
algorithm = data.get("signature_algorithm")
crypto_profile = data.get("crypto_profile")
if not isinstance(bundle_id, str) or not bundle_id:
raise VerificationError("trust bundle requires bundle_id")
if not isinstance(signer_ref, str) or not signer_ref:
raise VerificationError("trust bundle requires signer_ref")
if not isinstance(signature_hex, str) or not signature_hex:
raise VerificationError("trust bundle requires signature_hex")
if algorithm != FIPS_READY_ALGORITHM:
raise VerificationError("trust bundle signature_algorithm must be rsa-pss-sha256")
if crypto_profile != FIPS_READY_PROFILE:
raise VerificationError("trust bundle crypto_profile must be fips-140-3-compatible")
signer = root_keys.get(signer_ref)
if signer is None:
raise VerificationError(f"no trusted root key for signer_ref={signer_ref!r}")
signer.validate_lifecycle(now=now)
verify_rsa_pss_sha256(
payload=canonical_trust_bundle_payload(data),
signature_hex=signature_hex,
trusted_key=signer,
)
return load_trusted_keys(data, now=now)
52 changes: 51 additions & 1 deletion tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import pytest

from nlboot.verify import VerificationError, load_trusted_keys
from nlboot import verify
from nlboot.verify import VerificationError, load_trusted_keys, load_verified_trust_bundle

PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArBfEOS4UzauNLkcJB/JY
Expand All @@ -31,6 +32,21 @@ def key_doc(**overrides: object) -> dict[str, object]:
return {"keys": [key]}


def trust_bundle(**overrides: object) -> dict[str, object]:
bundle = key_doc()
bundle.update(
{
"bundle_id": "urn:srcos:trust-bundle:m2-demo-2026-04-26",
"signer_ref": "urn:srcos:key:sourceos-release-root",
"signature_algorithm": "rsa-pss-sha256",
"crypto_profile": "fips-140-3-compatible",
"signature_hex": "00",
}
)
bundle.update(overrides)
return bundle


def now() -> datetime:
return datetime(2026, 4, 26, 14, 35, tzinfo=timezone.utc)

Expand Down Expand Up @@ -65,3 +81,37 @@ def test_revoked_key_rejected():
),
now=now(),
)


def test_signed_trust_bundle_loads_after_root_verification(monkeypatch: pytest.MonkeyPatch):
calls: list[bytes] = []

def fake_verify(*, payload: bytes, signature_hex: str, trusted_key: object) -> None:
calls.append(payload)
assert signature_hex == "00"

root_keys = load_trusted_keys(key_doc(), now=now())
monkeypatch.setattr(verify, "verify_rsa_pss_sha256", fake_verify)
keys = load_verified_trust_bundle(trust_bundle(), root_keys=root_keys, now=now())
assert "urn:srcos:key:sourceos-release-root" in keys
assert calls == [verify.canonical_trust_bundle_payload(trust_bundle())]


def test_signed_trust_bundle_requires_root_signer():
with pytest.raises(VerificationError, match="no trusted root key"):
load_verified_trust_bundle(trust_bundle(signer_ref="urn:srcos:key:missing"), root_keys={}, now=now())


def test_signed_trust_bundle_rejects_non_fips_algorithm():
root_keys = load_trusted_keys(key_doc(), now=now())
with pytest.raises(VerificationError, match="signature_algorithm"):
load_verified_trust_bundle(trust_bundle(signature_algorithm="ed25519"), root_keys=root_keys, now=now())


def test_signed_trust_bundle_rejects_revoked_root():
root_keys = load_trusted_keys(key_doc(), now=now())
revoked_root = key_doc(status="revoked", revoked_at="2026-04-20T00:00:00Z")
root_keys["urn:srcos:key:sourceos-release-root"] = load_trusted_keys(key_doc(), now=now())["urn:srcos:key:sourceos-release-root"]
bundle = trust_bundle(keys=revoked_root["keys"])
with pytest.raises(VerificationError, match="revoked"):
load_verified_trust_bundle(bundle, root_keys=root_keys, now=now())
Loading