diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6969f4c..6a89ad6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -33,11 +33,11 @@ jobs: run: python -m pip install -e . pytest - name: Run validation run: make validate - - name: Validate M2 demo fixture through CLI + - name: Validate M2 recovery fixture through CLI run: | nlboot-plan \ - --manifest examples/m2-demo/manifest.recovery.json \ - --token examples/m2-demo/enrollment-token.recovery.json \ - --trusted-keys examples/m2-demo/trusted-keys.json \ + --manifest examples/signed_boot_manifest.recovery.json \ + --token examples/enrollment_token.recovery.json \ + --trusted-keys examples/trusted_keys.recovery.json \ --require-fips \ --now 2026-04-26T14:35:00Z diff --git a/README.md b/README.md index 505f17b..8f73429 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ This repository implements the safe planning core for the SourceOS / SociOS boot - validates one-time enrollment token intent, expiry, audience, and release/boot-release binding - produces a boot plan as JSON - records `execute=false` in produced plans +- emits SourceOS control-plane metadata in boot plans: + - `policy_ref` + - `allowed_operations` + - `proof_requirements` + - `offline_fallback` - never downloads artifacts, writes disks, calls `kexec`, or mutates a host in this reference slice ## Protocol objects @@ -39,22 +44,36 @@ This repository implements the safe planning core for the SourceOS / SociOS boot - matching `boot_release_set_ref` - purpose compatible with the boot mode +`BootPlan` is emitted only after manifest verification and token validation. It includes: + +- the selected plan action +- boot and release-set references +- artifact references +- signature and crypto profile metadata +- policy reference for the boot mode +- safe planning operations allowed by that boot mode +- proof requirements the eventual executor must satisfy +- offline fallback posture +- `execute=false` + +The planner is intentionally conservative. It creates an authorized plan record, not a host-mutating execution path. + ## M2 demo fixture -The repository carries a side-effect-free M2 recovery fixture under `examples/m2-demo/`: +The repository carries a side-effect-free M2 recovery fixture under `examples/`: -- `manifest.recovery.json` -- `enrollment-token.recovery.json` -- `trusted-keys.json` +- `signed_boot_manifest.recovery.json` +- `enrollment_token.recovery.json` +- `trusted_keys.recovery.json` Run it through the planner: ```bash python3 -m pip install -e . nlboot-plan \ - --manifest examples/m2-demo/manifest.recovery.json \ - --token examples/m2-demo/enrollment-token.recovery.json \ - --trusted-keys examples/m2-demo/trusted-keys.json \ + --manifest examples/signed_boot_manifest.recovery.json \ + --token examples/enrollment_token.recovery.json \ + --trusted-keys examples/trusted_keys.recovery.json \ --require-fips \ --now 2026-04-26T14:35:00Z ``` diff --git a/src/nlboot/protocol.py b/src/nlboot/protocol.py index 2261523..c81f396 100644 --- a/src/nlboot/protocol.py +++ b/src/nlboot/protocol.py @@ -173,6 +173,10 @@ class BootPlan: authorized_by: str signature_algorithm: str crypto_profile: str + policy_ref: str + allowed_operations: list[str] + proof_requirements: list[str] + offline_fallback: dict[str, Any] execute: bool = False def to_dict(self) -> dict[str, Any]: @@ -185,10 +189,49 @@ def to_dict(self) -> dict[str, Any]: "authorized_by": self.authorized_by, "signature_algorithm": self.signature_algorithm, "crypto_profile": self.crypto_profile, + "policy_ref": self.policy_ref, + "allowed_operations": self.allowed_operations, + "proof_requirements": self.proof_requirements, + "offline_fallback": self.offline_fallback, "execute": self.execute, } +def _policy_ref_for_mode(mode: BootMode) -> str: + return f"policy://sourceos/nlboot/{mode}/safe-plan-v1" + + +def _allowed_operations_for_mode(mode: BootMode) -> list[str]: + operations_by_mode: dict[BootMode, list[str]] = { + "recovery": ["present-menu", "verify-artifacts", "plan-recovery", "plan-rollback"], + "installer": ["present-menu", "verify-artifacts", "plan-install"], + "ephemeral": ["present-menu", "verify-artifacts", "plan-ephemeral-boot"], + "bootstrap": ["present-menu", "verify-artifacts", "plan-bootstrap"], + } + return operations_by_mode[mode] + + +def _proof_requirements_for_mode(mode: BootMode) -> list[str]: + common = [ + "verified_manifest_signature", + "validated_one_time_token", + "artifact_ref_manifest", + "boot_plan_record", + ] + if mode in {"recovery", "installer"}: + return [*common, "device_claim_record", "post_action_fingerprint"] + return [*common, "session_fingerprint"] + + +def _offline_fallback_for_mode(mode: BootMode) -> dict[str, Any]: + return { + "enabled": mode in {"recovery", "ephemeral"}, + "strategy": "last-known-good-signed-boot-release-set" if mode in {"recovery", "ephemeral"} else "none", + "requires_signature_verification": True, + "allows_unsigned_artifacts": False, + } + + def build_boot_plan(manifest: SignedBootManifest, token: EnrollmentToken, *, now: datetime | None = None) -> BootPlan: token.validate_for_manifest(manifest, now=now) action_by_mode: dict[BootMode, PlanAction] = { @@ -206,5 +249,9 @@ def build_boot_plan(manifest: SignedBootManifest, token: EnrollmentToken, *, now authorized_by=token.token_id, signature_algorithm=manifest.signature_algorithm, crypto_profile=manifest.crypto_profile, + policy_ref=_policy_ref_for_mode(manifest.boot_mode), + allowed_operations=_allowed_operations_for_mode(manifest.boot_mode), + proof_requirements=_proof_requirements_for_mode(manifest.boot_mode), + offline_fallback=_offline_fallback_for_mode(manifest.boot_mode), execute=False, ) diff --git a/tests/test_cli_examples.py b/tests/test_cli_examples.py index 9def75c..61f5d9e 100644 --- a/tests/test_cli_examples.py +++ b/tests/test_cli_examples.py @@ -42,3 +42,24 @@ def test_cli_examples_emit_safe_recovery_plan(): 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" + assert plan["policy_ref"] == "policy://sourceos/nlboot/recovery/safe-plan-v1" + assert plan["allowed_operations"] == [ + "present-menu", + "verify-artifacts", + "plan-recovery", + "plan-rollback", + ] + assert plan["proof_requirements"] == [ + "verified_manifest_signature", + "validated_one_time_token", + "artifact_ref_manifest", + "boot_plan_record", + "device_claim_record", + "post_action_fingerprint", + ] + assert plan["offline_fallback"] == { + "enabled": True, + "strategy": "last-known-good-signed-boot-release-set", + "requires_signature_verification": True, + "allows_unsigned_artifacts": False, + } diff --git a/tests/test_verify.py b/tests/test_verify.py index 6f6e473..6f29a1a 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -108,10 +108,14 @@ def test_signed_trust_bundle_rejects_non_fips_algorithm(): load_verified_trust_bundle(trust_bundle(signature_algorithm="ed25519"), root_keys=root_keys, now=now()) -def test_signed_trust_bundle_rejects_revoked_root(): +def test_signed_trust_bundle_rejects_revoked_root(monkeypatch: pytest.MonkeyPatch): + def fake_verify(*, payload: bytes, signature_hex: str, trusted_key: object) -> None: + assert payload == verify.canonical_trust_bundle_payload(bundle) + assert signature_hex == "00" + 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"]) + monkeypatch.setattr(verify, "verify_rsa_pss_sha256", fake_verify) with pytest.raises(VerificationError, match="revoked"): load_verified_trust_bundle(bundle, root_keys=root_keys, now=now())