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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion examples/signed_boot_manifest.recovery.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
9 changes: 9 additions & 0 deletions examples/trusted_keys.recovery.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 35 additions & 3 deletions src/nlboot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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

Expand Down
30 changes: 26 additions & 4 deletions src/nlboot/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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":
Expand All @@ -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}")
Expand All @@ -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,
)


Expand Down Expand Up @@ -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]:
Expand All @@ -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,
}

Expand All @@ -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,
)
75 changes: 75 additions & 0 deletions src/nlboot/verify.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions tests/test_cli_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand All @@ -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"
19 changes: 19 additions & 0 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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():
Expand Down Expand Up @@ -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)
Loading