diff --git a/Makefile b/Makefile index b0819e0..9ea5659 100644 --- a/Makefile +++ b/Makefile @@ -4,5 +4,5 @@ validate: test @echo "OK: validate" test: - python3 -m pip install --user pytest >/dev/null + python3 -m pip install --user pytest cryptography >/dev/null PYTHONPATH=src python3 -m pytest -q diff --git a/examples/signed_boot_manifest.recovery.json b/examples/signed_boot_manifest.recovery.json index ac276b9..236359f 100644 --- a/examples/signed_boot_manifest.recovery.json +++ b/examples/signed_boot_manifest.recovery.json @@ -9,5 +9,8 @@ "rootfs_ref": "urn:srcos:artifact:m2-demo-recovery-rootfs-sha256-5f4dcc3b" }, "signature_ref": "urn:srcos:signature:m2-demo-recovery-2026-04-26", - "signer_ref": "urn:srcos:key:sourceos-release-root" + "signer_ref": "urn:srcos:key:sourceos-release-root", + "signature_algorithm": "rsa-pss-sha256", + "crypto_profile": "fips-140-3-compatible", + "signature_hex": "56e76990dbbed93973e40284129249f61fb3c257e62c3b84a058a735a8ecb35a4abd8544144baf4437dd9e59123073d6005cf8b9cc0011aa16f25d4c6bbc48d1a6f9741d0c848262623c38dedc97457101f27b4ac22d854f91be4061e028024760136dfffe65293ea7bd4e2bc393be7f912f0d69eec72bfd92651b6924e37f1a1c4108bba99f322d5e0bf85581f540048e0eeb565ec71d8335139c3eb3a8f0b4f68a80e9e9ddc526de9017c8e6d0ab8d854755bfde2e36aedbc42e1885bca8124bb253f098116d2fae26657da5be6474305e765d71df2905ff82cd7465687b9c78827ecfb1e647a32c1c72716a3636de41e99f15959757fb03cd770dc5bb7e17" } diff --git a/examples/trusted_keys.recovery.json b/examples/trusted_keys.recovery.json new file mode 100644 index 0000000..d7ef302 --- /dev/null +++ b/examples/trusted_keys.recovery.json @@ -0,0 +1,9 @@ +{ + "keys": [ + { + "key_ref": "urn:srcos:key:sourceos-release-root", + "algorithm": "rsa-pss-sha256", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArBfEOS4UzauNLkcJB/JY\nUQy0qkPOyyj1Zm2dLd00KXxeoj6JnfUtqIsYz7lmiWOKQf4bJlpbl3acKkSSdIDv\no3h32zqNDBO8vlnX26ym7qBRhWd9BR6CZ5/+Qu/AYcMbbtQf5OYK65BBWEZQGDE1\n56ihXzaWoxZDAHt0FZpD8PgtCaCcXT5qmLYhk207cVdVpxJ9+knWisu2F6KPcgOh\nWwBevbIFfv/QYac0LupV/bXpGFiNMVbfWgHIT1s4plFXRBtdJG4maoIc8B6ln2pT\n+nDHozjCWDloI322WGabunZyZRrNzdg1eWM0Xk5XH1zeo+7hcxvjvUJeOVQtDs5v\ntwIDAQAB\n-----END PUBLIC KEY-----\n" + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 62e22d4..d939c57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } authors = [{ name = "SourceOS / SociOS" }] -dependencies = [] +dependencies = ["cryptography>=42"] [project.scripts] nlboot-plan = "nlboot.cli:main" diff --git a/src/nlboot/cli.py b/src/nlboot/cli.py index 54aa4d0..fb7a816 100644 --- a/src/nlboot/cli.py +++ b/src/nlboot/cli.py @@ -8,6 +8,14 @@ from typing import Any from .protocol import EnrollmentToken, NlbootError, SignedBootManifest, build_boot_plan +from .verify import ( + FIPS_READY_ALGORITHM, + FIPS_READY_PROFILE, + VerificationError, + canonical_payload, + load_trusted_keys, + verify_rsa_pss_sha256, +) def load_json(path: Path) -> dict[str, Any]: @@ -17,19 +25,43 @@ def load_json(path: Path) -> dict[str, Any]: return data +def verify_manifest_document(manifest_doc: dict[str, Any], trusted_keys_doc: dict[str, Any], *, require_fips: bool) -> None: + algorithm = manifest_doc.get("signature_algorithm") + profile = manifest_doc.get("crypto_profile") + signer_ref = manifest_doc.get("signer_ref") + signature_hex = manifest_doc.get("signature_hex") + if require_fips and (algorithm != FIPS_READY_ALGORITHM or profile != FIPS_READY_PROFILE): + raise VerificationError("require-fips requires rsa-pss-sha256 and fips-140-3-compatible profile") + if not isinstance(signer_ref, str): + raise VerificationError("manifest signer_ref must be a string") + if not isinstance(signature_hex, str): + raise VerificationError("manifest signature_hex must be a string") + trusted_keys = load_trusted_keys(trusted_keys_doc) + trusted_key = trusted_keys.get(signer_ref) + if trusted_key is None: + raise VerificationError(f"no trusted key for signer_ref={signer_ref!r}") + if trusted_key.algorithm != algorithm: + raise VerificationError("trusted key algorithm does not match manifest") + verify_rsa_pss_sha256(payload=canonical_payload(manifest_doc), signature_hex=signature_hex, trusted_key=trusted_key) + + def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Build a safe nlboot plan from a signed manifest and enrollment token") + parser = argparse.ArgumentParser(description="Build a safe nlboot plan from a verified manifest and enrollment token") parser.add_argument("--manifest", type=Path, required=True) parser.add_argument("--token", type=Path, required=True) + parser.add_argument("--trusted-keys", type=Path, required=True) + parser.add_argument("--require-fips", action="store_true") parser.add_argument("--now", help="Optional ISO-8601 override for tests") args = parser.parse_args(argv) try: now = datetime.fromisoformat(args.now.replace("Z", "+00:00")).astimezone(timezone.utc) if args.now else None - manifest = SignedBootManifest.from_dict(load_json(args.manifest)) + manifest_doc = load_json(args.manifest) + verify_manifest_document(manifest_doc, load_json(args.trusted_keys), require_fips=args.require_fips) + manifest = SignedBootManifest.from_dict(manifest_doc) token = EnrollmentToken.from_dict(load_json(args.token)) plan = build_boot_plan(manifest, token, now=now) - except (NlbootError, ValueError, json.JSONDecodeError) as exc: + except (NlbootError, VerificationError, ValueError, json.JSONDecodeError) as exc: print(f"nlboot-plan: {exc}", file=sys.stderr) return 2 diff --git a/src/nlboot/protocol.py b/src/nlboot/protocol.py index d7f4b7e..2261523 100644 --- a/src/nlboot/protocol.py +++ b/src/nlboot/protocol.py @@ -7,6 +7,10 @@ BootMode = Literal["installer", "recovery", "ephemeral", "bootstrap"] TokenPurpose = Literal["enroll", "boot", "repair", "recovery"] PlanAction = Literal["present-menu", "boot-recovery", "boot-installer", "boot-ephemeral", "bootstrap-only"] +SignatureAlgorithm = Literal["rsa-pss-sha256"] +CryptoProfile = Literal["fips-140-3-compatible"] +FIPS_READY_ALGORITHM = "rsa-pss-sha256" +FIPS_READY_PROFILE = "fips-140-3-compatible" class NlbootError(ValueError): @@ -33,11 +37,10 @@ def _parse_time(value: str, *, key: str) -> datetime: @dataclass(frozen=True) class SignedBootManifest: - """Signed-boot-manifest-shaped contract used by the safe planner. + """FIPS-ready signed boot manifest contract used by the safe planner. - The reference implementation validates intent and shape only. It does not perform cryptographic - verification in this slice. Signature fields are still required so a future verifier can replace - the placeholder check without changing the protocol object. + The protocol requires RSA-PSS/SHA-256 metadata and an explicit FIPS-ready profile. Full FIPS + compliance still depends on executing cryptography in a validated runtime module. """ manifest_id: str @@ -47,6 +50,9 @@ class SignedBootManifest: artifacts: dict[str, str] signature_ref: str signer_ref: str + signature_algorithm: SignatureAlgorithm + crypto_profile: CryptoProfile + signature_hex: str @classmethod def from_dict(cls, data: dict[str, Any]) -> "SignedBootManifest": @@ -55,6 +61,13 @@ def from_dict(cls, data: dict[str, Any]) -> "SignedBootManifest": base_release_set_ref = _require_string(data, "base_release_set_ref") signature_ref = _require_string(data, "signature_ref") signer_ref = _require_string(data, "signer_ref") + signature_algorithm = _require_string(data, "signature_algorithm") + crypto_profile = _require_string(data, "crypto_profile") + signature_hex = _require_string(data, "signature_hex") + if signature_algorithm != FIPS_READY_ALGORITHM: + raise NlbootError("signature_algorithm must be rsa-pss-sha256") + if crypto_profile != FIPS_READY_PROFILE: + raise NlbootError("crypto_profile must be fips-140-3-compatible") boot_mode = _require_string(data, "boot_mode") if boot_mode not in {"installer", "recovery", "ephemeral", "bootstrap"}: raise NlbootError(f"unsupported boot_mode={boot_mode!r}") @@ -75,6 +88,9 @@ def from_dict(cls, data: dict[str, Any]) -> "SignedBootManifest": artifacts={k: str(v) for k, v in artifacts.items()}, signature_ref=signature_ref, signer_ref=signer_ref, + signature_algorithm=signature_algorithm, # type: ignore[arg-type] + crypto_profile=crypto_profile, # type: ignore[arg-type] + signature_hex=signature_hex, ) @@ -155,6 +171,8 @@ class BootPlan: release_set_ref: str artifacts: dict[str, str] authorized_by: str + signature_algorithm: str + crypto_profile: str execute: bool = False def to_dict(self) -> dict[str, Any]: @@ -165,6 +183,8 @@ def to_dict(self) -> dict[str, Any]: "release_set_ref": self.release_set_ref, "artifacts": self.artifacts, "authorized_by": self.authorized_by, + "signature_algorithm": self.signature_algorithm, + "crypto_profile": self.crypto_profile, "execute": self.execute, } @@ -184,5 +204,7 @@ def build_boot_plan(manifest: SignedBootManifest, token: EnrollmentToken, *, now release_set_ref=manifest.base_release_set_ref, artifacts=manifest.artifacts, authorized_by=token.token_id, + signature_algorithm=manifest.signature_algorithm, + crypto_profile=manifest.crypto_profile, execute=False, ) diff --git a/src/nlboot/verify.py b/src/nlboot/verify.py new file mode 100644 index 0000000..59cf2f3 --- /dev/null +++ b/src/nlboot/verify.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +FIPS_READY_ALGORITHM = "rsa-pss-sha256" +FIPS_READY_PROFILE = "fips-140-3-compatible" + + +class VerificationError(ValueError): + """Raised when a signature or trusted-key check fails.""" + + +@dataclass(frozen=True) +class TrustedKey: + key_ref: str + algorithm: str + public_key_pem: str + + @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") + 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) + + +def load_trusted_keys(data: dict[str, Any]) -> 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) + 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 verify_rsa_pss_sha256(*, payload: bytes, signature_hex: str, trusted_key: TrustedKey) -> None: + try: + signature = bytes.fromhex(signature_hex) + except ValueError as exc: + raise VerificationError("signature_hex must be hex") from exc + public_key = serialization.load_pem_public_key(trusted_key.public_key_pem.encode("utf-8")) + if not isinstance(public_key, rsa.RSAPublicKey): + raise VerificationError("trusted key must be RSA") + if public_key.key_size < 2048: + raise VerificationError("RSA key must be at least 2048 bits") + try: + public_key.verify( + signature, + payload, + padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32), + hashes.SHA256(), + ) + except InvalidSignature as exc: + raise VerificationError("signature verification failed") from exc diff --git a/tests/test_cli_examples.py b/tests/test_cli_examples.py index 005e103..9def75c 100644 --- a/tests/test_cli_examples.py +++ b/tests/test_cli_examples.py @@ -21,6 +21,9 @@ def test_cli_examples_emit_safe_recovery_plan(): str(root / "examples" / "signed_boot_manifest.recovery.json"), "--token", str(root / "examples" / "enrollment_token.recovery.json"), + "--trusted-keys", + str(root / "examples" / "trusted_keys.recovery.json"), + "--require-fips", "--now", "2026-04-26T14:35:00Z", ], @@ -37,3 +40,5 @@ def test_cli_examples_emit_safe_recovery_plan(): assert plan["execute"] is False assert plan["boot_release_set_id"] == "urn:srcos:boot-release-set:m2-demo-recovery-2026-04-26" assert plan["release_set_ref"] == "urn:srcos:release-set:m2-demo-2026-04-26" + assert plan["signature_algorithm"] == "rsa-pss-sha256" + assert plan["crypto_profile"] == "fips-140-3-compatible" diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 9aa05cf..43faefa 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -19,6 +19,9 @@ }, "signature_ref": "urn:srcos:signature:m2-demo-recovery", "signer_ref": "urn:srcos:key:sourceos-release-root", + "signature_algorithm": "rsa-pss-sha256", + "crypto_profile": "fips-140-3-compatible", + "signature_hex": "00", } TOKEN = { @@ -42,6 +45,8 @@ def test_builds_safe_recovery_plan(): assert plan.execute is False assert plan.boot_release_set_id == MANIFEST["boot_release_set_id"] assert plan.authorized_by == TOKEN["token_id"] + assert plan.signature_algorithm == "rsa-pss-sha256" + assert plan.crypto_profile == "fips-140-3-compatible" def test_expired_token_rejected(): @@ -74,3 +79,17 @@ def test_wrong_purpose_for_recovery_rejected(): token = EnrollmentToken.from_dict(bad) with pytest.raises(NlbootError, match="purpose"): build_boot_plan(manifest, token, now=datetime(2026, 4, 26, 14, 35, tzinfo=timezone.utc)) + + +def test_non_fips_ready_algorithm_rejected(): + bad = dict(MANIFEST) + bad["signature_algorithm"] = "ed25519" + with pytest.raises(NlbootError, match="rsa-pss-sha256"): + SignedBootManifest.from_dict(bad) + + +def test_non_fips_ready_profile_rejected(): + bad = dict(MANIFEST) + bad["crypto_profile"] = "standard" + with pytest.raises(NlbootError, match="fips-140-3-compatible"): + SignedBootManifest.from_dict(bad)