diff --git a/examples/nlboot/manifest.json b/examples/nlboot/manifest.json new file mode 100644 index 0000000..ac4f76a --- /dev/null +++ b/examples/nlboot/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_id": "manifest-demo-1", + "boot_release_set_id": "boot/demo", + "base_release_set_ref": "release/demo", + "boot_mode": "recovery", + "artifacts": { + "kernel_ref": "https://example.invalid/kernel", + "initrd_ref": "https://example.invalid/initrd", + "rootfs_ref": "https://example.invalid/rootfs" + }, + "signature_ref": "urn:srcos:signature:demo", + "signer_ref": "trusted-key-1", + "signature_algorithm": "rsa-pss-sha256", + "crypto_profile": "fips-140-3-compatible" +} diff --git a/examples/nlboot/token.json b/examples/nlboot/token.json new file mode 100644 index 0000000..57b5e24 --- /dev/null +++ b/examples/nlboot/token.json @@ -0,0 +1,7 @@ +{ + "token_id": "token-demo-1", + "purpose": "recovery", + "expires_at": "2026-04-26T01:00:00Z", + "release_set_ref": "release/demo", + "boot_release_set_ref": "boot/demo" +} diff --git a/pyproject.toml b/pyproject.toml index b7268e6..e931cf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,9 @@ version = "0.1.0" description = "SourceOS Boot/Recovery: BootReleaseSet, secure live boot, rollback, and nlboot evolution." requires-python = ">=3.11" +[project.scripts] +sourceos-boot = "sourceos_boot.cli:main" + [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] diff --git a/src/sourceos_boot/cli.py b/src/sourceos_boot/cli.py new file mode 100644 index 0000000..0bc8a9b --- /dev/null +++ b/src/sourceos_boot/cli.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""SourceOS Boot command-line helpers.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +from .adapter import DeviceClaim, SourceOSBootAdapter + + +def load_json(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"expected JSON object in {path}") + return data + + +def canonical_json_sha256(data: dict[str, Any]) -> str: + payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8") + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def adapt_nlboot(args: argparse.Namespace) -> int: + manifest_doc = load_json(args.manifest) + token_doc = load_json(args.token) + adapter = SourceOSBootAdapter() + + claim = DeviceClaim( + device_id=args.device_id, + public_key_fingerprint=args.public_key_fingerprint, + platform=args.platform, + nonce=args.nonce, + ) + authorization = adapter.authorization_from_nlboot_token(token_doc, correlation_id=args.correlation_id) + patch = adapter.boot_release_set_patch_from_nlboot_manifest(manifest_doc) + evidence = adapter.build_evidence_from_nlboot_manifest( + claim=claim, + authorization=authorization, + manifest_doc=manifest_doc, + manifest_hash=canonical_json_sha256(manifest_doc), + verification_result=args.verification_result, + ) + + output = { + "apiVersion": "sourceos.dev/v1", + "kind": "NlbootAdapterOutput", + "authorization": authorization.to_dict(), + "bootReleaseSetPatch": patch, + "evidence": evidence.to_dict(), + } + print(json.dumps(output, indent=2, sort_keys=True)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="SourceOS Boot helpers") + subparsers = parser.add_subparsers(dest="command", required=True) + + adapt = subparsers.add_parser("adapt-nlboot", help="Convert nlboot manifest/token JSON into SourceOS handoff objects") + adapt.add_argument("--manifest", type=Path, required=True) + adapt.add_argument("--token", type=Path, required=True) + adapt.add_argument("--device-id", required=True) + adapt.add_argument("--public-key-fingerprint", required=True) + adapt.add_argument("--platform", required=True) + adapt.add_argument("--nonce", required=True) + adapt.add_argument("--correlation-id", required=True) + adapt.add_argument("--verification-result", choices=["pass", "fail", "unknown"], default="pass") + adapt.set_defaults(func=adapt_nlboot) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except Exception as exc: # noqa: BLE001 + print(f"sourceos-boot: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1ef0819 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path + +from sourceos_boot.cli import main + + +def test_adapt_nlboot_cli_emits_handoff_objects(capsys) -> None: + root = Path(__file__).resolve().parents[1] + rc = main([ + "adapt-nlboot", + "--manifest", + str(root / "examples" / "nlboot" / "manifest.json"), + "--token", + str(root / "examples" / "nlboot" / "token.json"), + "--device-id", + "device-1", + "--public-key-fingerprint", + "sha256:demo", + "--platform", + "apple-silicon", + "--nonce", + "nonce-1", + "--correlation-id", + "corr-1", + ]) + assert rc == 0 + output = json.loads(capsys.readouterr().out) + assert output["kind"] == "NlbootAdapterOutput" + assert output["authorization"]["bootReleaseSetRef"] == "boot/demo" + assert output["bootReleaseSetPatch"]["channels"] == ["recovery"] + assert output["evidence"]["deviceId"] == "device-1"