diff --git a/contracts/testing-package-release.v1.md b/contracts/testing-package-release.v1.md index eade9e84..c15a567a 100644 --- a/contracts/testing-package-release.v1.md +++ b/contracts/testing-package-release.v1.md @@ -6,6 +6,15 @@ single symbolic entrypoint mapping. All paths, byte lengths, digests, source rev revisions, runtime requirements, and creation inputs are exact release values; floating references and ambient workspace identities are not release inputs. +Successor releases add `authority` and `tool_catalog` together while the committed legacy release +remains valid without either member. Consumer policy verifies the exact release-byte pin first, then +the authority validity window, release-sequence floor, and revoked-key set before authorization, +DSSE, dependent-artifact reads, extraction, module loading, or executor effects. The tool catalog is +closed to the verified `browser.read-title.v1` to `browser_read_title` mapping for this profile. +The unpublished successor schema profiles remain in `schemas-next-release/` until the schema catalog +and signed schema release can be updated atomically. Deterministic signing seeds used by tests are +public test-only fixtures and are never production signing material. + The bundle and release metadata use recursively sorted compact UTF-8 JSON with exactly one final LF. The package manifest retains `fkst-testing-package-manifest-canonical-json.v1` and has no final LF. Bundle records are sorted by UTF-8 path bytes and bind canonical standard base64, decoded size, and diff --git a/schemas-next-release/testing-package-release.v1.schema.json b/schemas-next-release/testing-package-release.v1.schema.json new file mode 100644 index 00000000..5d55f4ca --- /dev/null +++ b/schemas-next-release/testing-package-release.v1.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chronoaiproject.github.io/fkst-packages-testing/schemas/testing-package-release.v1.schema.json", + "title": "Testing Package Release v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "canonicalization", "package", "bundle", "manifest", "schema_catalog", "schema_release", "source", "producer", "runtime", "executor", "reducer", "result_authority", "mappings", "creation_metadata"], + "oneOf": [{"not": {"anyOf": [{"required": ["authority"]}, {"required": ["tool_catalog"]}]}}, {"required": ["authority", "tool_catalog"]}], + "properties": { + "schema": {"const": "testing-package-release.v1"}, + "canonicalization": {"const": "fkst-testing-package-release-canonical-json.v1"}, + "package": { + "type": "object", "additionalProperties": false, + "required": ["package_id", "package_version", "package_content_sha256", "supported_profile", "capability"], + "properties": { + "package_id": {"const": "testing-runner"}, + "package_version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "package_content_sha256": {"$ref": "#/$defs/sha256"}, + "supported_profile": {"const": "browser-deterministic.v1"}, + "capability": {"const": "browser.read-title.v1"} + } + }, + "bundle": {"$ref": "#/$defs/fileBinding"}, + "manifest": {"$ref": "#/$defs/manifestBinding"}, + "schema_catalog": {"$ref": "#/$defs/fileBinding"}, + "schema_release": {"$ref": "#/$defs/fileBinding"}, + "tool_catalog": {"$ref": "#/$defs/fileBinding"}, + "authority": { + "type": "object", "additionalProperties": false, + "required": ["issuer", "keyid", "release_sequence", "revocation_authority", "signature_profile", "valid_from", "valid_until"], + "properties": { + "issuer": {"const": "https://releases.chronoaiproject.org/fkst-packages-testing"}, + "keyid": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\u0000-\u001f\u007f-\u009f]+$"}, + "release_sequence": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, + "revocation_authority": {"const": "https://releases.chronoaiproject.org/fkst-packages-testing/revocations/v1"}, + "signature_profile": {"const": "dsse-ed25519.v1"}, + "valid_from": {"$ref": "#/$defs/timestamp"}, + "valid_until": {"$ref": "#/$defs/timestamp"} + } + }, + "source": { + "type": "object", "additionalProperties": false, + "required": ["repository_commit", "fkst_packages_commit", "fkst_substrate_commit"], + "properties": { + "repository_commit": {"$ref": "#/$defs/commit"}, + "fkst_packages_commit": {"$ref": "#/$defs/commit"}, + "fkst_substrate_commit": {"$ref": "#/$defs/commit"} + } + }, + "producer": { + "type": "object", "additionalProperties": false, + "required": ["name", "version", "generator", "generator_version"], + "properties": { + "name": {"const": "fkst-packages-testing"}, + "version": {"$ref": "#/$defs/semver"}, + "generator": {"const": "scripts/generate_testing_package_release.py"}, + "generator_version": {"$ref": "#/$defs/semver"} + } + }, + "runtime": { + "type": "object", "additionalProperties": false, + "required": ["lua", "platform"], + "properties": {"lua": {"const": "5.4.0"}, "platform": {"const": "linux-amd64"}} + }, + "executor": { + "type": "object", "additionalProperties": false, + "required": ["module", "function", "executor_id"], + "properties": { + "module": {"const": "testing_package_executor.executor"}, + "function": {"const": "execute"}, + "executor_id": {"const": "testing-package-executor.browser-title.v1"} + } + }, + "reducer": { + "type": "object", "additionalProperties": false, + "required": ["schema", "reducer_id", "reducer_version", "reducer_sha256", "policy_profile", "supported_result_contract_majors"], + "properties": { + "schema": {"const": "testing-assertion-reducer-identity.v1"}, + "reducer_id": {"const": "testing.assertion-reducer.browser-title-equals"}, + "reducer_version": {"const": "1.0.0"}, + "reducer_sha256": {"$ref": "#/$defs/sha256"}, + "policy_profile": {"const": "browser-title-equals.v1"}, + "supported_result_contract_majors": {"type": "array", "items": {"const": "testing-case-result-set.v2"}, "minItems": 1, "maxItems": 1} + } + }, + "result_authority": { + "type": "object", "additionalProperties": false, + "required": ["receipt_schema"], + "properties": {"receipt_schema": {"const": "testing-result-authority-receipt.v1"}} + }, + "mappings": { + "type": "array", "minItems": 1, "maxItems": 1, + "items": { + "type": "object", "additionalProperties": false, + "required": ["entrypoint", "contract_major", "module", "function"], + "properties": { + "entrypoint": {"const": "testing-runner.run"}, + "contract_major": {"const": "testing-runner.v1"}, + "module": {"const": "testing_package_executor.executor"}, + "function": {"const": "execute"} + } + } + }, + "creation_metadata": { + "type": "object", "additionalProperties": false, + "required": ["created_at", "build_id"], + "properties": { + "created_at": {"const": "2026-09-04T00:00:00Z"}, + "build_id": {"const": "testing-package-release-walking-skeleton-v1"} + } + } + }, + "$defs": { + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "semver": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z$"}, + "path": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?(/|$))(?!.*\\\\)[^\\u0000-\\u001f]+$"}, + "fileBinding": { + "type": "object", "additionalProperties": false, + "required": ["path", "size_bytes", "sha256"], + "properties": { + "path": {"$ref": "#/$defs/path"}, + "size_bytes": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, + "sha256": {"$ref": "#/$defs/sha256"} + } + }, + "manifestBinding": { + "type": "object", "additionalProperties": false, + "required": ["path", "size_bytes", "sha256", "manifest_digest"], + "properties": { + "path": {"$ref": "#/$defs/path"}, + "size_bytes": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, + "sha256": {"$ref": "#/$defs/sha256"}, + "manifest_digest": {"$ref": "#/$defs/sha256"} + } + } + }, + "x-fkst-canonicalization": "fkst-testing-package-release-canonical-json.v1" +} diff --git a/schemas-next-release/testing-package-tool-catalog.v1.schema.json b/schemas-next-release/testing-package-tool-catalog.v1.schema.json new file mode 100644 index 00000000..6fd51ae9 --- /dev/null +++ b/schemas-next-release/testing-package-tool-catalog.v1.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chronoaiproject.github.io/fkst-packages-testing/schemas/testing-package-tool-catalog.v1.schema.json", + "title": "Testing Package Tool Catalog v1", + "type": "object", + "additionalProperties": false, + "required": ["canonicalization", "execution_profile", "schema", "tools"], + "properties": { + "canonicalization": {"const": "fkst-testing-package-tool-catalog-canonical-json.v1"}, + "execution_profile": {"const": "browser-deterministic.v1"}, + "schema": {"const": "testing-package-tool-catalog.v1"}, + "tools": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["capability", "port"], + "properties": { + "capability": {"const": "browser.read-title.v1"}, + "port": {"const": "browser_read_title"} + } + } + } + }, + "x-fkst-canonicalization": "fkst-testing-package-tool-catalog-canonical-json.v1" +} diff --git a/scripts/generate_testing_package_release.py b/scripts/generate_testing_package_release.py index da0cc5ac..9f27aa3c 100755 --- a/scripts/generate_testing_package_release.py +++ b/scripts/generate_testing_package_release.py @@ -12,6 +12,8 @@ import re import subprocess import tempfile +import unicodedata +from datetime import datetime, timezone from pathlib import Path @@ -33,6 +35,10 @@ CREATED_AT = "2026-09-04T00:00:00Z" BUILD_ID = "testing-package-release-walking-skeleton-v1" VERSION = "1.0.0" +AUTHORITY_ISSUER = "https://releases.chronoaiproject.org/fkst-packages-testing" +SIGNATURE_PROFILE = "dsse-ed25519.v1" +REVOCATION_AUTHORITY = "https://releases.chronoaiproject.org/fkst-packages-testing/revocations/v1" +TOOL_CATALOG_PATH = "package-release/testing-package-tool-catalog.v1.json" COMMIT = re.compile(r"^[0-9a-f]{40}$") BUNDLE_FILES = ( "libraries/contract/canonical_json.lua", @@ -57,13 +63,25 @@ def sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() -def binding(path: Path) -> dict[str, object]: +def binding(path: Path, logical_path: str | None = None) -> dict[str, object]: data = path.read_bytes() - return {"path": path.relative_to(ROOT).as_posix(), "size_bytes": len(data), "sha256": sha256(data)} + return {"path": logical_path or path.relative_to(ROOT).as_posix(), "size_bytes": len(data), "sha256": sha256(data)} -def byte_binding(path: Path, data: bytes) -> dict[str, object]: - return {"path": path.relative_to(ROOT).as_posix(), "size_bytes": len(data), "sha256": sha256(data)} +def byte_binding(logical_path: str, data: bytes) -> dict[str, object]: + return {"path": logical_path, "size_bytes": len(data), "sha256": sha256(data)} + + +def canonical_timestamp(value: str, field: str) -> str: + if re.fullmatch(r"[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z", value) is None: + raise ValueError(f"{field} must be a canonical UTC timestamp") + try: + parsed = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError as error: + raise ValueError(f"{field} must be a canonical UTC timestamp") from error + if parsed.strftime("%Y-%m-%dT%H:%M:%SZ") != value: + raise ValueError(f"{field} must be a canonical UTC timestamp") + return value def exact_commit(value: str, field: str) -> str: @@ -128,7 +146,7 @@ def bundle(source: str) -> tuple[dict[str, object], str]: return {"files": records, "schema": "testing-package-bundle.v1"}, content_digest.hexdigest() -def manifest(package_content_sha256: str, source: str, packages: str, substrate: str) -> dict[str, object]: +def manifest(package_content_sha256: str, source: str, packages: str, substrate: str, created_at: str) -> dict[str, object]: generator = load_manifest_generator() value = { "schema": generator.SCHEMA, @@ -153,7 +171,7 @@ def manifest(package_content_sha256: str, source: str, packages: str, substrate: "fkst_substrate": {"id": "fkst-substrate", "commit": substrate}, }, "producer": {"name": "fkst-packages-testing", "version": VERSION, "toolchain": "testing-package-release-v1"}, - "creation_metadata": {"created_at": CREATED_AT, "build_id": BUILD_ID}, + "creation_metadata": {"created_at": created_at, "build_id": BUILD_ID}, } value["manifest_digest"] = sha256(generator.canonical(value)) return value @@ -171,10 +189,19 @@ def reducer_identity() -> dict[str, object]: return value -def release_value(package_content_sha256: str, bundle_bytes: bytes, manifest_bytes: bytes, manifest_value: dict[str, object], source: str, packages: str, substrate: str) -> dict[str, object]: - manifest_binding = byte_binding(MANIFEST_PATH, manifest_bytes) - manifest_binding["manifest_digest"] = manifest_value["manifest_digest"] +def tool_catalog() -> dict[str, object]: return { + "canonicalization": "fkst-testing-package-tool-catalog-canonical-json.v1", + "execution_profile": "browser-deterministic.v1", + "schema": "testing-package-tool-catalog.v1", + "tools": [{"capability": "browser.read-title.v1", "port": "browser_read_title"}], + } + + +def release_value(package_content_sha256: str, bundle_bytes: bytes, manifest_bytes: bytes, manifest_value: dict[str, object], source: str, packages: str, substrate: str, created_at: str, authority: dict[str, object] | None = None, tool_catalog_bytes: bytes | None = None) -> dict[str, object]: + manifest_binding = byte_binding("package-release/testing-package-manifest.v1.json", manifest_bytes) + manifest_binding["manifest_digest"] = manifest_value["manifest_digest"] + value = { "schema": "testing-package-release.v1", "canonicalization": "fkst-testing-package-release-canonical-json.v1", "package": { @@ -182,7 +209,7 @@ def release_value(package_content_sha256: str, bundle_bytes: bytes, manifest_byt "package_content_sha256": package_content_sha256, "supported_profile": "browser-deterministic.v1", "capability": "browser.read-title.v1", }, - "bundle": byte_binding(BUNDLE_PATH, bundle_bytes), + "bundle": byte_binding("package-release/testing-package-bundle.v1.json", bundle_bytes), "manifest": manifest_binding, "schema_catalog": binding(CATALOG_PATH), "schema_release": binding(SCHEMA_RELEASE_PATH), @@ -193,8 +220,12 @@ def release_value(package_content_sha256: str, bundle_bytes: bytes, manifest_byt "reducer": reducer_identity(), "result_authority": {"receipt_schema": "testing-result-authority-receipt.v1"}, "mappings": [{"entrypoint": "testing-runner.run", "contract_major": "testing-runner.v1", "module": "testing_package_executor.executor", "function": "execute"}], - "creation_metadata": {"created_at": CREATED_AT, "build_id": BUILD_ID}, + "creation_metadata": {"created_at": created_at, "build_id": BUILD_ID}, } + if authority is not None and tool_catalog_bytes is not None: + value["authority"] = authority + value["tool_catalog"] = byte_binding(TOOL_CATALOG_PATH, tool_catalog_bytes) + return value def statement(release_bytes: bytes) -> bytes: @@ -234,7 +265,7 @@ def signing_seed(seed_file: Path | None) -> bytes: return decode_seed(value) -def signed_artifacts(release_bytes: bytes, seed: bytes) -> tuple[bytes, bytes]: +def signed_artifacts(release_bytes: bytes, seed: bytes, keyid: str = KEY_ID) -> tuple[bytes, bytes]: payload = statement(release_bytes) with tempfile.TemporaryDirectory(prefix="testing-package-release-sign-") as directory: root = Path(directory) @@ -252,17 +283,27 @@ def signed_artifacts(release_bytes: bytes, seed: bytes) -> tuple[bytes, bytes]: raise ValueError("OpenSSL returned a malformed Ed25519 signature") envelope = { "payload": base64.b64encode(payload).decode("ascii"), "payloadType": PAYLOAD_TYPE, - "signatures": [{"keyid": KEY_ID, "sig": base64.b64encode(signature).decode("ascii")}], + "signatures": [{"keyid": keyid, "sig": base64.b64encode(signature).decode("ascii")}], } authorization = { "algorithm": "ed25519", "authorization": {"payloadType": PAYLOAD_TYPE, "predicateType": PREDICATE_TYPE, "subject": SUBJECT_NAME}, - "keyid": KEY_ID, "publicKey": base64.b64encode(public_key).decode("ascii"), + "keyid": keyid, "publicKey": base64.b64encode(public_key).decode("ascii"), "schema": "testing-package-release-key-authorization.v1", } return compact(envelope), compact(authorization) +def valid_keyid(value: str) -> bool: + if not isinstance(value, str): + return False + try: + encoded = value.encode("utf-8") + except UnicodeEncodeError: + return False + return 1 <= len(encoded) <= 128 and all(unicodedata.category(character) not in {"Cc", "Cs"} for character in value) + + def verify_signed_artifacts(release_bytes: bytes) -> None: envelope_bytes = ENVELOPE_PATH.read_bytes() authorization_bytes = AUTHORIZATION_PATH.read_bytes() @@ -297,26 +338,69 @@ def verify_signed_artifacts(release_bytes: bytes) -> None: raise ValueError("committed Ed25519 signature verification failed") -def unsigned_outputs(source_commit: str) -> dict[Path, bytes]: +def unsigned_outputs(source_commit: str, *, output_root: Path = ROOT, fkst_packages_commit: str | None = None, fkst_substrate_commit: str | None = None, created_at: str = CREATED_AT, authority: dict[str, object] | None = None) -> dict[Path, bytes]: source = repository_commit(source_commit) - packages = pinned_commit(ROOT / ".fkst/conformance/fkst-packages.pin", "fkst-packages") - substrate = pinned_commit(ROOT / ".fkst/substrate-ref", "fkst-substrate") + packages = exact_commit(fkst_packages_commit, "fkst-packages commit") if fkst_packages_commit is not None else pinned_commit(ROOT / ".fkst/conformance/fkst-packages.pin", "fkst-packages") + substrate = exact_commit(fkst_substrate_commit, "fkst-substrate commit") if fkst_substrate_commit is not None else pinned_commit(ROOT / ".fkst/substrate-ref", "fkst-substrate") + package_root = output_root / "package-release" + bundle_path = package_root / "testing-package-bundle.v1.json" + manifest_path = package_root / "testing-package-manifest.v1.json" + release_path = package_root / "testing-package-release.v1.json" bundle_value, package_content_sha256 = bundle(source) bundle_bytes = compact(bundle_value) - manifest_value = manifest(package_content_sha256, source, packages, substrate) + manifest_value = manifest(package_content_sha256, source, packages, substrate, created_at) manifest_bytes = load_manifest_generator().canonical(manifest_value) - release_bytes = compact(release_value(package_content_sha256, bundle_bytes, manifest_bytes, manifest_value, source, packages, substrate)) - return {BUNDLE_PATH: bundle_bytes, MANIFEST_PATH: manifest_bytes, RELEASE_PATH: release_bytes} + catalog_bytes = compact(tool_catalog()) if authority is not None else None + release_bytes = compact(release_value(package_content_sha256, bundle_bytes, manifest_bytes, manifest_value, source, packages, substrate, created_at, authority, catalog_bytes)) + outputs = {bundle_path: bundle_bytes, manifest_path: manifest_bytes, release_path: release_bytes} + if catalog_bytes is not None: + outputs[package_root / "testing-package-tool-catalog.v1.json"] = catalog_bytes + return outputs def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true") + parser.add_argument("--output-directory", type=Path) parser.add_argument("--seed-file", type=Path) parser.add_argument("--source-commit", required=True) + parser.add_argument("--fkst-packages-commit") + parser.add_argument("--fkst-substrate-commit") + parser.add_argument("--authority-issuer") + parser.add_argument("--authority-keyid") + parser.add_argument("--signature-profile") + parser.add_argument("--valid-from") + parser.add_argument("--valid-until") + parser.add_argument("--revocation-authority") + parser.add_argument("--release-sequence", type=int) + parser.add_argument("--created-at", default=CREATED_AT) arguments = parser.parse_args() try: - expected = unsigned_outputs(arguments.source_commit) + authority_arguments = [arguments.authority_issuer, arguments.authority_keyid, arguments.signature_profile, arguments.valid_from, arguments.valid_until, arguments.revocation_authority, arguments.release_sequence] + successor = arguments.output_directory is not None + if successor and (arguments.check or arguments.fkst_packages_commit is None or arguments.fkst_substrate_commit is None or any(value is None for value in authority_arguments)): + raise ValueError("isolated successor generation requires every explicit dependency and authority input") + if not successor and any(value is not None for value in authority_arguments): + raise ValueError("authority inputs require --output-directory") + authority = None + keyid = KEY_ID + output_root = ROOT + if successor: + if arguments.authority_issuer != AUTHORITY_ISSUER or arguments.signature_profile != SIGNATURE_PROFILE or arguments.revocation_authority != REVOCATION_AUTHORITY: + raise ValueError("successor authority profile is unsupported") + if not isinstance(arguments.release_sequence, int) or not 1 <= arguments.release_sequence <= 9007199254740991: + raise ValueError("release sequence must be a positive safe integer") + keyid = arguments.authority_keyid + if not valid_keyid(keyid): + raise ValueError("authority keyid is invalid") + valid_from = canonical_timestamp(arguments.valid_from, "valid-from") + valid_until = canonical_timestamp(arguments.valid_until, "valid-until") + if valid_from >= valid_until: + raise ValueError("authority validity interval must be non-empty") + authority = {"issuer": arguments.authority_issuer, "keyid": keyid, "release_sequence": arguments.release_sequence, "revocation_authority": arguments.revocation_authority, "signature_profile": arguments.signature_profile, "valid_from": valid_from, "valid_until": valid_until} + output_root = arguments.output_directory.resolve() + created_at = canonical_timestamp(arguments.created_at, "created-at") + expected = unsigned_outputs(arguments.source_commit, output_root=output_root, fkst_packages_commit=arguments.fkst_packages_commit, fkst_substrate_commit=arguments.fkst_substrate_commit, created_at=created_at, authority=authority) if arguments.check: for path, data in expected.items(): if not path.is_file() or path.read_bytes() != data: @@ -326,10 +410,14 @@ def main() -> int: verify_signed_artifacts(expected[RELEASE_PATH]) else: for path, data in expected.items(): + path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) - envelope, authorization = signed_artifacts(expected[RELEASE_PATH], signing_seed(arguments.seed_file)) - ENVELOPE_PATH.write_bytes(envelope) - AUTHORIZATION_PATH.write_bytes(authorization) + release_path = output_root / SUBJECT_NAME + envelope, authorization = signed_artifacts(expected[release_path], signing_seed(arguments.seed_file), keyid) + envelope_path = output_root / "package-release/testing-package-release.v1.dsse.json" + authorization_path = output_root / "package-release/testing-package-release.v1.key.json" + envelope_path.write_bytes(envelope) + authorization_path.write_bytes(authorization) except (OSError, ValueError, subprocess.CalledProcessError) as error: parser.exit(1, f"error: {error}\n") print("testing-package-release-generation: PASS") diff --git a/scripts/testing_package_release_test.py b/scripts/testing_package_release_test.py index 9c42dba3..625977a2 100755 --- a/scripts/testing_package_release_test.py +++ b/scripts/testing_package_release_test.py @@ -36,6 +36,8 @@ ROOT / "package-release/testing-package-release.v1.source-commit" ).read_text(encoding="ascii").strip() VERIFIER = ROOT / "scripts/verify_testing_package_release.mjs" +TEST_ONLY_PUBLIC_SIGNING_SEED_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +MULTIBYTE_OVERFLOW_KEYID = "é" * 64 + "a" def run_verifier( @@ -54,6 +56,10 @@ def run_verifier( command.extend([ "--trusted-authorization-sha256", authorization_pin, + "--verification-time", + "2026-09-04T12:00:00Z", + "--minimum-release-sequence", + "2", "--authorization", str(authorization), ]) @@ -163,15 +169,16 @@ def signed_case(root: Path, mutate, *, target: str, message: str, allowed: tuple def assert_rejection_matrix() -> None: legitimate_pin = hashlib.sha256(AUTHORIZATION.read_bytes()).hexdigest() expected = ["--expected-release-sha256", RELEASE_SHA256] + policy = ["--verification-time", "2026-09-04T12:00:00Z", "--minimum-release-sequence", "2"] cli = [ - ([*expected, "--release", str(ARTIFACTS["release"])], "--trusted-authorization-sha256 is required exactly once"), - ([*expected, "--trusted-authorization-sha256", legitimate_pin.upper()], "exactly 64 lowercase hexadecimal"), - ([*expected, "--trusted-authorization-sha256", legitimate_pin[:-1]], "exactly 64 lowercase hexadecimal"), - ([*expected, "--trusted-authorization-sha256", "g" * 64], "exactly 64 lowercase hexadecimal"), - ([*expected, "--trusted-authorization-sha256", legitimate_pin, "--trusted-authorization-sha256", legitimate_pin], "arguments must be unique"), - ([*expected, "--trusted-authorization-sha256", "--release", str(ARTIFACTS["release"])], "arguments must be unique"), - ([*expected, "--trusted-authorization-sha256", legitimate_pin, "--release", str(ARTIFACTS["release"]), "--release", str(ARTIFACTS["release"])], "arguments must be unique"), - ([*expected, "--trusted-authorization-sha256", legitimate_pin, "--unknown", "value"], "unknown argument"), + ([*expected, *policy, "--release", str(ARTIFACTS["release"])], "--trusted-authorization-sha256 is required exactly once"), + ([*expected, "--trusted-authorization-sha256", legitimate_pin.upper(), *policy], "exactly 64 lowercase hexadecimal"), + ([*expected, "--trusted-authorization-sha256", legitimate_pin[:-1], *policy], "exactly 64 lowercase hexadecimal"), + ([*expected, "--trusted-authorization-sha256", "g" * 64, *policy], "exactly 64 lowercase hexadecimal"), + ([*expected, "--trusted-authorization-sha256", legitimate_pin, "--trusted-authorization-sha256", legitimate_pin, *policy], "arguments must be unique"), + ([*expected, "--trusted-authorization-sha256", "--release", str(ARTIFACTS["release"]), *policy], "arguments must be unique"), + ([*expected, "--trusted-authorization-sha256", legitimate_pin, *policy, "--release", str(ARTIFACTS["release"]), "--release", str(ARTIFACTS["release"])], "arguments must be unique"), + ([*expected, "--trusted-authorization-sha256", legitimate_pin, *policy, "--unknown", "value"], "unknown argument"), ] for arguments, message in cli: result = subprocess.run(["node", str(VERIFIER), *arguments], cwd=ROOT, text=True, capture_output=True) @@ -221,8 +228,8 @@ def assert_rejection_matrix() -> None: result = run_verifier(envelope_pin, authorization=envelope_authorization, paths={"envelope": envelope_path}, stage_log=log, success=False) assert_rejection(result, log, "DSSE envelope bytes are not canonical", ("trust-pin-matched", "public-key-imported")) cases = [ - ("release", lambda value: value.update(schema="wrong"), "release profile is unsupported", ("trust-pin-matched", "public-key-imported", "dsse-verified")), - ("release", lambda value: value["mappings"].append(copy.deepcopy(value["mappings"][0])), "exactly one mapping", ("trust-pin-matched", "public-key-imported", "dsse-verified")), + ("release", lambda value: value.update(schema="wrong"), "release profile is unsupported", ()), + ("release", lambda value: value["mappings"].append(copy.deepcopy(value["mappings"][0])), "exactly one mapping", ()), ("manifest", lambda value: value["runtime_requirements"].update(lua="5.3.0"), "manifest runtime requirements are unsupported", ("trust-pin-matched", "public-key-imported", "dsse-verified", "release-verified")), ("manifest", lambda value: value["entrypoints"].append(copy.deepcopy(value["entrypoints"][0])), "manifest must expose exactly testing-runner.run", ("trust-pin-matched", "public-key-imported", "dsse-verified", "release-verified")), ("bundle", lambda value: value["files"].append({**value["files"][-1], "path": "libraries/unexpected.lua"}), "bundle files do not match the release allowlist", ("trust-pin-matched", "public-key-imported", "dsse-verified", "release-verified", "manifest-verified")), @@ -250,6 +257,10 @@ def assert_rejection_matrix() -> None: def assert_generator_rejections() -> None: + assert generator.valid_keyid("é" * 64) + assert len(MULTIBYTE_OVERFLOW_KEYID.encode("utf-8")) == 129 + for invalid_keyid in ("bad\u0085key", MULTIBYTE_OVERFLOW_KEYID): + assert not generator.valid_keyid(invalid_keyid) variable = generator.SEED_ENVIRONMENT_VARIABLE original = os.environ.get(variable) seed = base64.b64encode(hashlib.sha256(b"generator-matrix").digest()).decode() @@ -285,6 +296,106 @@ def assert_generator_rejections() -> None: os.environ[variable] = original +def assert_successor_walking_skeleton(registry) -> None: + tracked = tuple(sorted((ROOT / "package-release").glob("*"))) + tuple(sorted((ROOT / "schema-release").glob("*"))) + snapshots = {path: path.read_bytes() for path in tracked if path.is_file()} + expected_paths = { + "package-release/testing-package-bundle.v1.json", + "package-release/testing-package-manifest.v1.json", + "package-release/testing-package-release.v1.json", + "package-release/testing-package-release.v1.dsse.json", + "package-release/testing-package-release.v1.key.json", + "package-release/testing-package-tool-catalog.v1.json", + } + with tempfile.TemporaryDirectory(prefix="testing-package-successor-") as directory: + parent = Path(directory) + seed_path = parent / "test-only-public-ed25519-seed.base64" + seed_path.write_text(TEST_ONLY_PUBLIC_SIGNING_SEED_BASE64, encoding="ascii") + roots = [parent / "first", parent / "second"] + for output_root in roots: + subprocess.run([ + sys.executable, + str(ROOT / "scripts/generate_testing_package_release.py"), + "--output-directory", str(output_root), + "--seed-file", str(seed_path), + "--source-commit", SOURCE_COMMIT, + "--fkst-packages-commit", "1111111111111111111111111111111111111111", + "--fkst-substrate-commit", "2222222222222222222222222222222222222222", + "--authority-issuer", "https://releases.chronoaiproject.org/fkst-packages-testing", + "--authority-keyid", "fkst-packages-testing-successor-test-v1", + "--signature-profile", "dsse-ed25519.v1", + "--valid-from", "2026-09-04T00:00:00Z", + "--valid-until", "2026-09-05T00:00:00Z", + "--revocation-authority", "https://releases.chronoaiproject.org/fkst-packages-testing/revocations/v1", + "--release-sequence", "2", + "--created-at", "2026-09-04T00:00:00Z", + ], cwd=ROOT, check=True) + actual_paths = {path.relative_to(output_root).as_posix() for path in output_root.rglob("*") if path.is_file()} + assert actual_paths == expected_paths + + for relative in expected_paths: + assert (roots[0] / relative).read_bytes() == (roots[1] / relative).read_bytes() + + release_path = roots[0] / "package-release/testing-package-release.v1.json" + authorization_path = roots[0] / "package-release/testing-package-release.v1.key.json" + tool_catalog_path = roots[0] / "package-release/testing-package-tool-catalog.v1.json" + release = json.loads(release_path.read_bytes()) + catalog = json.loads(tool_catalog_path.read_bytes()) + successor_schema_paths = tuple(path for path in sorted((ROOT / "schemas").glob("*.schema.json")) if path.name != "testing-package-release.v1.schema.json") + ( + ROOT / "schemas-next-release/testing-package-release.v1.schema.json", + ROOT / "schemas-next-release/testing-package-tool-catalog.v1.schema.json", + ) + successor_registry = offline_registry(successor_schema_paths) + _, release_validator = validator_for_schema_file(ROOT / "schemas-next-release/testing-package-release.v1.schema.json", registry=successor_registry) + _, catalog_validator = validator_for_schema_file(ROOT / "schemas-next-release/testing-package-tool-catalog.v1.schema.json", registry=successor_registry) + assert not tuple(release_validator.iter_errors(release)) + assert not tuple(catalog_validator.iter_errors(catalog)) + invalid_control_release = copy.deepcopy(release) + invalid_control_release["authority"]["keyid"] = "bad\u0085key" + assert tuple(release_validator.iter_errors(invalid_control_release)) + assert tool_catalog_path.read_bytes() == b'{"canonicalization":"fkst-testing-package-tool-catalog-canonical-json.v1","execution_profile":"browser-deterministic.v1","schema":"testing-package-tool-catalog.v1","tools":[{"capability":"browser.read-title.v1","port":"browser_read_title"}]}\n' + assert release["authority"] == { + "issuer": "https://releases.chronoaiproject.org/fkst-packages-testing", + "keyid": "fkst-packages-testing-successor-test-v1", + "release_sequence": 2, + "revocation_authority": "https://releases.chronoaiproject.org/fkst-packages-testing/revocations/v1", + "signature_profile": "dsse-ed25519.v1", + "valid_from": "2026-09-04T00:00:00Z", + "valid_until": "2026-09-05T00:00:00Z", + } + stage_log = parent / "successor-stages.log" + result = run_verifier( + hashlib.sha256(authorization_path.read_bytes()).hexdigest(), + expected_release_sha256=hashlib.sha256(release_path.read_bytes()).hexdigest(), + authorization=authorization_path, + paths={ + "release": release_path, + "envelope": roots[0] / "package-release/testing-package-release.v1.dsse.json", + "bundle": roots[0] / "package-release/testing-package-bundle.v1.json", + "manifest": roots[0] / "package-release/testing-package-manifest.v1.json", + "tool-catalog": tool_catalog_path, + "schema-catalog": ROOT / "schema-release/testing-schema-catalog.v1.json", + "schema-release": ROOT / "schema-release/testing-package-schema-release.v1.json", + }, + stage_log=stage_log, + success=True, + ) + assert "testing-package-release: VERIFIED AND EXECUTED" in result.stdout + assert_stages(stage_log, SUCCESS_STAGES) + for index, invalid_keyid in enumerate(("bad\u0085key", MULTIBYTE_OVERFLOW_KEYID)): + invalid_log = parent / f"invalid-keyid-{index}.log" + invalid_result = run_verifier( + extra_arguments=("--revoked-keyid", invalid_keyid), + stage_log=invalid_log, + success=False, + ) + assert "--revoked-keyid is invalid" in invalid_result.stderr + assert_stages(invalid_log, []) + + for path, expected in snapshots.items(): + assert path.read_bytes() == expected, path + + def main() -> int: assert hashlib.sha256(RELEASE.read_bytes()).hexdigest() == RELEASE_SHA256 assert hashlib.sha256(AUTHORIZATION.read_bytes()).hexdigest() == AUTHORIZATION_SHA256 @@ -307,6 +418,7 @@ def main() -> int: invalid = generator.json.loads((ROOT / "packages/testing-runner/tests/fixtures/testing-package-release.v1/invalid-unknown-field.json").read_text()) assert not tuple(validator.iter_errors(valid)) assert tuple(validator.iter_errors(invalid)) + assert_successor_walking_skeleton(registry) with tempfile.TemporaryDirectory(prefix="testing-package-release-positive-") as directory: stage_log = Path(directory) / "stages.log" diff --git a/scripts/verify_testing_package_release.mjs b/scripts/verify_testing_package_release.mjs index 7e9f627f..cca561ac 100755 --- a/scripts/verify_testing_package_release.mjs +++ b/scripts/verify_testing_package_release.mjs @@ -12,10 +12,14 @@ const STATEMENT_TYPE = "https://in-toto.io/Statement/v1"; const PREDICATE_TYPE = "https://chronoaiproject.github.io/fkst-packages-testing/attestations/testing-package-release/v1"; const SUBJECT_NAME = "package-release/testing-package-release.v1.json"; const KEY_ID = "fkst-packages-testing-release-v1-2026-09-04"; +const AUTHORITY_ISSUER = "https://releases.chronoaiproject.org/fkst-packages-testing"; +const SIGNATURE_PROFILE = "dsse-ed25519.v1"; +const REVOCATION_AUTHORITY = "https://releases.chronoaiproject.org/fkst-packages-testing/revocations/v1"; const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); const HEX_64 = /^[0-9a-f]{64}$/; const HEX_40 = /^[0-9a-f]{40}$/; const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const UTC_TIMESTAMP = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\dZ$/; const BUNDLE_PATHS = [ "libraries/contract/canonical_json.lua", "libraries/contract/error_facts.lua", @@ -83,29 +87,64 @@ function fileBinding(value, field, manifest = false) { if (!Number.isSafeInteger(value.size_bytes) || value.size_bytes < 1) fail(`${field}.size_bytes is invalid`); if (!HEX_64.test(value.sha256) || (manifest && !HEX_64.test(value.manifest_digest))) fail(`${field} digest is invalid`); } +function timestamp(value, field) { + if (typeof value !== "string" || !UTC_TIMESTAMP.test(value) || new Date(value).toISOString().replace(".000Z", "Z") !== value) fail(`${field} must be a canonical UTC timestamp`); + return Date.parse(value); +} +function keyid(value, field) { + if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value, "utf8") > 128) fail(`${field} is invalid`); + for (const character of value) { + const codepoint = character.codePointAt(0); + if (codepoint <= 0x1f || (codepoint >= 0x7f && codepoint <= 0x9f) || (codepoint >= 0xd800 && codepoint <= 0xdfff)) fail(`${field} is invalid`); + } + return value; +} async function stage(name) { const log = process.env.FKST_TESTING_PACKAGE_RELEASE_STAGE_LOG; if (log) await writeFile(log, `${name}\n`, { flag: "a" }); } function parseArguments(argv) { const values = new Map(); + const revokedKeyids = new Set(); for (let index = 0; index < argv.length; index += 2) { const name = argv[index], value = argv[index + 1]; - if (!name?.startsWith("--") || value === undefined || value.startsWith("--") || values.has(name)) fail("arguments must be unique --name value pairs"); + if (!name?.startsWith("--") || value === undefined || value.startsWith("--")) fail("arguments must be unique --name value pairs"); + if (name === "--revoked-keyid") { + keyid(value, "--revoked-keyid"); + if (revokedKeyids.has(value)) fail("--revoked-keyid values must be unique"); + revokedKeyids.add(value); + continue; + } + if (values.has(name)) fail("arguments must be unique --name value pairs"); values.set(name, value); } - const allowed = new Set(["--expected-release-sha256", "--trusted-authorization-sha256", "--release", "--envelope", "--authorization", "--bundle", "--manifest", "--schema-catalog", "--schema-release"]); + const allowed = new Set(["--expected-release-sha256", "--trusted-authorization-sha256", "--verification-time", "--minimum-release-sequence", "--release", "--envelope", "--authorization", "--bundle", "--manifest", "--tool-catalog", "--schema-catalog", "--schema-release"]); for (const name of values.keys()) if (!allowed.has(name)) fail(`unknown argument ${name}`); if (!values.has("--expected-release-sha256")) fail("--expected-release-sha256 is required exactly once"); if (!values.has("--trusted-authorization-sha256")) fail("--trusted-authorization-sha256 is required exactly once"); - return values; + if (!values.has("--verification-time")) fail("--verification-time is required exactly once"); + if (!values.has("--minimum-release-sequence")) fail("--minimum-release-sequence is required exactly once"); + timestamp(values.get("--verification-time"), "--verification-time"); + if (!/^[1-9][0-9]*$/.test(values.get("--minimum-release-sequence"))) fail("--minimum-release-sequence must be a positive safe decimal integer"); + const minimumReleaseSequence = Number(values.get("--minimum-release-sequence")); + if (!Number.isSafeInteger(minimumReleaseSequence)) fail("--minimum-release-sequence must be a positive safe decimal integer"); + return { values, revokedKeyids, minimumReleaseSequence }; } function verifyReleaseShape(release) { - closed(release, ["schema", "canonicalization", "package", "bundle", "manifest", "schema_catalog", "schema_release", "source", "producer", "runtime", "executor", "reducer", "result_authority", "mappings", "creation_metadata"], "release"); + const successor = Object.hasOwn(release, "authority") || Object.hasOwn(release, "tool_catalog"); + closed(release, ["schema", "canonicalization", "package", "bundle", "manifest", "schema_catalog", "schema_release", "source", "producer", "runtime", "executor", "reducer", "result_authority", "mappings", "creation_metadata", ...(successor ? ["authority", "tool_catalog"] : [])], "release"); if (release.schema !== "testing-package-release.v1" || release.canonicalization !== "fkst-testing-package-release-canonical-json.v1") fail("release profile is unsupported"); closed(release.package, ["package_id", "package_version", "package_content_sha256", "supported_profile", "capability"], "release.package"); if (release.package.package_id !== "testing-runner" || release.package.package_version !== "1.0.0" || !HEX_64.test(release.package.package_content_sha256) || release.package.supported_profile !== "browser-deterministic.v1" || release.package.capability !== "browser.read-title.v1") fail("release package identity is unsupported"); fileBinding(release.bundle, "release.bundle"); fileBinding(release.manifest, "release.manifest", true); fileBinding(release.schema_catalog, "release.schema_catalog"); fileBinding(release.schema_release, "release.schema_release"); + if (successor) { + fileBinding(release.tool_catalog, "release.tool_catalog"); + closed(release.authority, ["issuer", "keyid", "release_sequence", "revocation_authority", "signature_profile", "valid_from", "valid_until"], "release.authority"); + keyid(release.authority.keyid, "release.authority.keyid"); + if (release.authority.issuer !== AUTHORITY_ISSUER || release.authority.signature_profile !== SIGNATURE_PROFILE || release.authority.revocation_authority !== REVOCATION_AUTHORITY || !Number.isSafeInteger(release.authority.release_sequence) || release.authority.release_sequence < 1) fail("release authority profile is unsupported"); + timestamp(release.authority.valid_from, "release.authority.valid_from"); timestamp(release.authority.valid_until, "release.authority.valid_until"); + if (release.authority.valid_from >= release.authority.valid_until) fail("release authority validity interval is empty"); + } closed(release.source, ["repository_commit", "fkst_packages_commit", "fkst_substrate_commit"], "release.source"); if (![release.source.repository_commit, release.source.fkst_packages_commit, release.source.fkst_substrate_commit].every((value) => HEX_40.test(value))) fail("release source identities must be exact commits"); closed(release.producer, ["name", "version", "generator", "generator_version"], "release.producer"); @@ -123,7 +162,9 @@ function verifyReleaseShape(release) { closed(release.mappings[0], ["entrypoint", "contract_major", "module", "function"], "release.mapping"); if (JSON.stringify(release.mappings[0]) !== JSON.stringify({ contract_major: "testing-runner.v1", entrypoint: "testing-runner.run", function: "execute", module: "testing_package_executor.executor" })) fail("release mapping is unsupported"); closed(release.creation_metadata, ["created_at", "build_id"], "release.creation_metadata"); - if (release.creation_metadata.created_at !== "2026-09-04T00:00:00Z" || release.creation_metadata.build_id !== "testing-package-release-walking-skeleton-v1") fail("release creation metadata is unsupported"); + timestamp(release.creation_metadata.created_at, "release.creation_metadata.created_at"); + if (release.creation_metadata.build_id !== "testing-package-release-walking-skeleton-v1") fail("release creation metadata is unsupported"); + return successor; } function verifyManifest(manifestBytes, manifest, release) { requireCanonical(manifestBytes, manifest, "manifest", false); @@ -151,6 +192,17 @@ function verifyManifest(manifestBytes, manifest, release) { closed(manifest.creation_metadata, ["created_at", "build_id"], "manifest.creation_metadata"); if (JSON.stringify(manifest.creation_metadata) !== JSON.stringify({ build_id: "testing-package-release-walking-skeleton-v1", created_at: "2026-09-04T00:00:00Z" })) fail("manifest creation metadata is unsupported"); } +function verifyToolCatalog(catalogBytes, catalog, release) { + requireCanonical(catalogBytes, catalog, "tool catalog"); + closed(catalog, ["canonicalization", "execution_profile", "schema", "tools"], "tool catalog"); + if (catalog.schema !== "testing-package-tool-catalog.v1" || catalog.canonicalization !== "fkst-testing-package-tool-catalog-canonical-json.v1" || catalog.execution_profile !== release.package.supported_profile || !Array.isArray(catalog.tools) || catalog.tools.length !== 1) fail("tool catalog profile is unsupported"); + closed(catalog.tools[0], ["capability", "port"], "tool catalog entry"); + if (catalog.tools[0].capability !== release.package.capability || catalog.tools[0].port !== "browser_read_title") fail("tool catalog executor port binding is unsupported"); + if (sha256(catalogBytes) !== release.tool_catalog.sha256 || catalogBytes.length !== release.tool_catalog.size_bytes) fail("tool catalog persisted binding mismatch"); +} +function inputMatchesLogicalPath(inputPath, logicalPath) { + return path.resolve(inputPath).split(path.sep).join("/").endsWith(`/${logicalPath}`); +} function verifyBundle(bundleBytes, bundle, release) { requireCanonical(bundleBytes, bundle, "bundle"); closed(bundle, ["files", "schema"], "bundle"); @@ -212,7 +264,12 @@ local ports = { complete_execution=function(value) return value end, now=function() clock_index=clock_index+1; return clock[clock_index] end, sha256=sha256, } +local captured_bindings +local create_receipt = authority.create_receipt +authority.create_receipt = function(bindings, sha256_fn) captured_bindings = bindings; return create_receipt(bindings, sha256_fn) end local receipt = executor.execute(resolved, ports) +authority.create_receipt = create_receipt +authority.validate_receipt(receipt, captured_bindings, sha256) authority.canonicalize(receipt, sha256) assert(receipt.schema == "testing-result-authority-receipt.v1") assert(receipt.classification == "passed") @@ -245,7 +302,8 @@ async function executeVerified(decodedFiles, release) { } export async function verifyTestingPackageRelease(argv = process.argv.slice(2)) { - const args = parseArguments(argv); + const parsedArguments = parseArguments(argv); + const args = parsedArguments.values; const expectedReleaseSha256 = args.get("--expected-release-sha256"); const pin = args.get("--trusted-authorization-sha256"); if (!HEX_64.test(expectedReleaseSha256)) fail("--expected-release-sha256 must be exactly 64 lowercase hexadecimal characters"); @@ -257,6 +315,16 @@ export async function verifyTestingPackageRelease(argv = process.argv.slice(2)) if (!digestMatches(releaseSha256, expectedReleaseSha256)) fail("release descriptor SHA-256 does not match the independently provisioned expected digest"); await stage("release-digest-matched"); + const release = parseJson(releaseBytes, "release"); requireCanonical(releaseBytes, release, "release"); + const successor = verifyReleaseShape(release); + const verificationTime = timestamp(args.get("--verification-time"), "--verification-time"); + if (successor) { + if (release.authority.release_sequence < parsedArguments.minimumReleaseSequence) fail("release sequence is below the consumer minimum"); + if (verificationTime < timestamp(release.authority.valid_from, "release.authority.valid_from") || verificationTime >= timestamp(release.authority.valid_until, "release.authority.valid_until")) fail("release is outside its authorized validity interval"); + if (parsedArguments.revokedKeyids.has(release.authority.keyid)) fail("release signing key is revoked"); + } + const expectedKeyid = successor ? release.authority.keyid : KEY_ID; + const authorizationPath = args.get("--authorization") ?? path.join(ROOT, "package-release/testing-package-release.v1.key.json"); const authorizationBytes = await readFile(authorizationPath); if (!digestMatches(sha256(authorizationBytes), pin)) fail("authorization record SHA-256 does not match the independently provisioned trust pin"); @@ -265,21 +333,23 @@ export async function verifyTestingPackageRelease(argv = process.argv.slice(2)) const authorization = parseJson(authorizationBytes, "authorization record"); requireCanonical(authorizationBytes, authorization, "authorization record"); closed(authorization, ["algorithm", "authorization", "keyid", "publicKey", "schema"], "authorization record"); closed(authorization.authorization, ["payloadType", "predicateType", "subject"], "authorization scope"); - if (authorization.algorithm !== "ed25519" || authorization.schema !== "testing-package-release-key-authorization.v1" || authorization.keyid !== KEY_ID || authorization.authorization.payloadType !== PAYLOAD_TYPE || authorization.authorization.predicateType !== PREDICATE_TYPE || authorization.authorization.subject !== SUBJECT_NAME) fail("authorization record profile is unsupported"); + if (authorization.algorithm !== "ed25519" || authorization.schema !== "testing-package-release-key-authorization.v1" || authorization.keyid !== expectedKeyid || authorization.authorization.payloadType !== PAYLOAD_TYPE || authorization.authorization.predicateType !== PREDICATE_TYPE || authorization.authorization.subject !== SUBJECT_NAME) fail("authorization record profile is unsupported"); const publicKey = decodeBase64(authorization.publicKey, "authorization publicKey", 32); await stage("public-key-imported"); const envelopePath = args.get("--envelope") ?? path.join(ROOT, "package-release/testing-package-release.v1.dsse.json"); const bundlePath = args.get("--bundle") ?? path.join(ROOT, "package-release/testing-package-bundle.v1.json"); const manifestPath = args.get("--manifest") ?? path.join(ROOT, "package-release/testing-package-manifest.v1.json"); + const toolCatalogPath = args.get("--tool-catalog"); const catalogPath = args.get("--schema-catalog") ?? path.join(ROOT, "schema-release/testing-schema-catalog.v1.json"); const schemaReleasePath = args.get("--schema-release") ?? path.join(ROOT, "schema-release/testing-package-schema-release.v1.json"); - const [envelopeBytes, bundleBytes, manifestBytes, catalogBytes, schemaReleaseBytes] = await Promise.all([envelopePath, bundlePath, manifestPath, catalogPath, schemaReleasePath].map((file) => readFile(file))); + if (successor && !toolCatalogPath) fail("--tool-catalog is required for successor releases"); + const [envelopeBytes, bundleBytes, manifestBytes, toolCatalogBytes, catalogBytes, schemaReleaseBytes] = await Promise.all([envelopePath, bundlePath, manifestPath, toolCatalogPath, catalogPath, schemaReleasePath].map((file) => file ? readFile(file) : null)); const envelope = parseJson(envelopeBytes, "DSSE envelope"); requireCanonical(envelopeBytes, envelope, "DSSE envelope"); closed(envelope, ["payload", "payloadType", "signatures"], "DSSE envelope"); if (envelope.payloadType !== PAYLOAD_TYPE || !Array.isArray(envelope.signatures) || envelope.signatures.length !== 1) fail("DSSE envelope profile is unsupported"); closed(envelope.signatures[0], ["keyid", "sig"], "DSSE signature"); - if (envelope.signatures[0].keyid !== KEY_ID) fail("DSSE keyid is unsupported"); + if (envelope.signatures[0].keyid !== expectedKeyid) fail("DSSE keyid is unsupported"); const payload = decodeBase64(envelope.payload, "DSSE payload"); const signature = decodeBase64(envelope.signatures[0].sig, "DSSE signature", 64); const key = createPublicKey({ key: Buffer.concat([SPKI_PREFIX, publicKey]), format: "der", type: "spki" }); @@ -290,9 +360,9 @@ export async function verifyTestingPackageRelease(argv = process.argv.slice(2)) if (statement._type !== STATEMENT_TYPE || statement.predicateType !== PREDICATE_TYPE || !Array.isArray(statement.subject) || statement.subject.length !== 1) fail("DSSE statement profile is unsupported"); closed(statement.subject[0], ["digest", "name"], "DSSE subject"); closed(statement.subject[0].digest, ["sha256"], "DSSE subject digest"); if (statement.subject[0].name !== SUBJECT_NAME || statement.subject[0].digest.sha256 !== sha256(releaseBytes)) fail("DSSE release digest binding mismatch"); - const release = parseJson(releaseBytes, "release"); requireCanonical(releaseBytes, release, "release"); verifyReleaseShape(release); - if (release.bundle.path !== path.relative(ROOT, bundlePath).split(path.sep).join("/") || release.manifest.path !== path.relative(ROOT, manifestPath).split(path.sep).join("/") || release.schema_catalog.path !== path.relative(ROOT, catalogPath).split(path.sep).join("/") || release.schema_release.path !== path.relative(ROOT, schemaReleasePath).split(path.sep).join("/")) fail("release bound paths do not match verifier inputs"); + if (!inputMatchesLogicalPath(bundlePath, release.bundle.path) || !inputMatchesLogicalPath(manifestPath, release.manifest.path) || !inputMatchesLogicalPath(catalogPath, release.schema_catalog.path) || !inputMatchesLogicalPath(schemaReleasePath, release.schema_release.path) || (successor && !inputMatchesLogicalPath(toolCatalogPath, release.tool_catalog.path))) fail("release bound paths do not match verifier inputs"); if (sha256(catalogBytes) !== release.schema_catalog.sha256 || catalogBytes.length !== release.schema_catalog.size_bytes || sha256(schemaReleaseBytes) !== release.schema_release.sha256 || schemaReleaseBytes.length !== release.schema_release.size_bytes) fail("schema publication binding mismatch"); + if (successor) verifyToolCatalog(toolCatalogBytes, parseJson(toolCatalogBytes, "tool catalog"), release); await stage("release-verified"); const manifest = parseJson(manifestBytes, "manifest"); verifyManifest(manifestBytes, manifest, release); await stage("manifest-verified");