diff --git a/src/nlboot/verify.py b/src/nlboot/verify.py index f58ad0a..d24c716 100644 --- a/src/nlboot/verify.py +++ b/src/nlboot/verify.py @@ -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) @@ -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) diff --git a/tests/test_verify.py b/tests/test_verify.py index 79b2b1e..6f6e473 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -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 @@ -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) @@ -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())