From 9cac1169cbd76ac730f8822b2ad1bc9c5868922c Mon Sep 17 00:00:00 2001 From: boburning Date: Mon, 31 Aug 2026 13:32:13 -0400 Subject: [PATCH 1/2] feat(sync): define authenticated save bundle contract --- .github/workflows/shell-test.yml | 2 + Makefile | 6 + build/save-sync-bundle.schema.json | 124 +++++++ build/save-sync-contract.json | 127 ++++++++ build/save-sync-contract.schema.json | 130 ++++++++ docs/1.0_TEST_MATRIX.md | 1 + docs/ARCHITECTURE.md | 29 ++ docs/RELEASE_PROCESS.md | 6 + docs/SAVE_SYNC.md | 126 ++++++++ docs/SECURITY.md | 18 ++ docs/program/BloomOS_Work_Program_v3.json | 8 +- test/shell/save_sync_contract.bats | 232 +++++++++++++ test/test_ci_scope.sh | 19 ++ tools/ci-scope.sh | 10 + tools/validate_save_sync_contract.py | 377 ++++++++++++++++++++++ 15 files changed, 1212 insertions(+), 3 deletions(-) create mode 100644 build/save-sync-bundle.schema.json create mode 100644 build/save-sync-contract.json create mode 100644 build/save-sync-contract.schema.json create mode 100644 docs/SAVE_SYNC.md create mode 100644 test/shell/save_sync_contract.bats create mode 100644 tools/validate_save_sync_contract.py diff --git a/.github/workflows/shell-test.yml b/.github/workflows/shell-test.yml index 14a179697..e449f1f67 100644 --- a/.github/workflows/shell-test.yml +++ b/.github/workflows/shell-test.yml @@ -34,6 +34,7 @@ on: - 'tools/display_built_in_profiles.py' - 'tools/display_visual_pack_providers.py' - 'tools/validate_content_identity_policy.py' + - 'tools/validate_save_sync_contract.py' - 'tools/validate_libretro_artwork_policy.py' - 'tools/ci-scope.sh' - 'tools/bloom-device' @@ -48,6 +49,7 @@ on: - 'build/display-visual-pack-providers.json' - 'static/build/.tmp_update/res/bloom/visual-packs/**' - 'build/content-identity-policy.json' + - 'build/save-sync-*.json' - 'build/libretro-artwork-policy.json' - 'build/ra-core-policy.json' - 'build/dependencies.lock' diff --git a/Makefile b/Makefile index 24e55a339..09ce570a2 100644 --- a/Makefile +++ b/Makefile @@ -310,6 +310,12 @@ package-release-unsigned: --policy $(ROOT_DIR)/build/save-migration-policy.json \ --schema $(ROOT_DIR)/build/save-migration-policy.schema.json \ --core-policy $(ROOT_DIR)/build/core-selection-policy.json + @python3 $(ROOT_DIR)/tools/validate_save_sync_contract.py \ + --contract $(ROOT_DIR)/build/save-sync-contract.json \ + --contract-schema $(ROOT_DIR)/build/save-sync-contract.schema.json \ + --bundle-schema $(ROOT_DIR)/build/save-sync-bundle.schema.json \ + --core-policy $(ROOT_DIR)/build/core-selection-policy.json \ + --content-policy $(ROOT_DIR)/build/content-identity-policy.json @python3 $(ROOT_DIR)/tools/validate_transfer_policy.py \ --policy $(ROOT_DIR)/build/transfer-policy.json \ --schema $(ROOT_DIR)/build/transfer-policy.schema.json diff --git a/build/save-sync-bundle.schema.json b/build/save-sync-bundle.schema.json new file mode 100644 index 000000000..673082c7b --- /dev/null +++ b/build/save-sync-bundle.schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bloomos.dev/schemas/save-sync-bundle-v1.json", + "title": "BloomOS authenticated immutable save bundle envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema", "bundle_id", "manifest", "authentication"], + "properties": { + "schema": {"const": 1}, + "bundle_id": {"type": "string", "pattern": "^bloom-save-bundle-v1:[0-9a-f]{64}$"}, + "manifest": {"$ref": "#/$defs/manifest"}, + "authentication": {"$ref": "#/$defs/authentication"} + }, + "$defs": { + "safeId": {"type": "string", "maxLength": 96, "pattern": "^[a-z0-9][a-z0-9._-]*$"}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "gameId": {"type": "string", "pattern": "^bloom-game-v1:[0-9a-f]{64}$"}, + "contentIdentity": {"type": "string", "maxLength": 160, "pattern": "^bloom-content:[a-z][a-z0-9-]{0,63}:v[1-9][0-9]*:[0-9a-f]{64}$"}, + "deviceId": {"type": "string", "pattern": "^bloom-device-v1:[0-9a-f]{32}$"}, + "keyId": {"type": "string", "pattern": "^bloom-sync-key-v1:[0-9a-f]{64}$"}, + "lineageId": {"type": "string", "pattern": "^bloom-save-lineage-v1:[0-9a-f]{32}$"}, + "softwareId": {"type": "string", "maxLength": 64, "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$"}, + "backend": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["emulator_id", "core_id", "component_id", "component_sha256"], + "properties": { + "emulator_id": {"type": "string", "pattern": "^libretro:[a-z0-9][a-z0-9._-]*$"}, + "core_id": {"$ref": "#/$defs/safeId"}, + "component_id": {"$ref": "#/$defs/safeId"}, + "component_sha256": {"$ref": "#/$defs/sha256"} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["emulator_id", "component_id", "component_sha256"], + "properties": { + "emulator_id": {"type": "string", "pattern": "^standalone:[a-z0-9][a-z0-9._-]*$"}, + "component_id": {"$ref": "#/$defs/safeId"}, + "component_sha256": {"$ref": "#/$defs/sha256"} + } + } + ] + }, + "sourceDevice": { + "type": "object", + "additionalProperties": false, + "required": ["device_id", "key_epoch"], + "properties": { + "device_id": {"$ref": "#/$defs/deviceId"}, + "key_epoch": {"type": "integer", "minimum": 1, "maximum": 2147483647} + } + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["format", "sha256", "bytes"], + "properties": { + "format": {"const": "bloom-save-payload-v1"}, + "sha256": {"$ref": "#/$defs/sha256"}, + "bytes": {"type": "integer", "minimum": 0, "maximum": 67108864} + } + }, + "software": { + "type": "object", + "additionalProperties": false, + "required": ["bloomos_version", "source_commit"], + "properties": { + "bloomos_version": {"$ref": "#/$defs/softwareId"}, + "source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"} + } + }, + "manifest": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "bloom_game_id", + "content_identity", + "system_id", + "save_kind", + "compatibility_id", + "backend", + "source_device", + "lineage_id", + "generation", + "parent_payload_sha256", + "payload", + "software" + ], + "properties": { + "schema": {"const": 1}, + "bloom_game_id": {"$ref": "#/$defs/gameId"}, + "content_identity": {"$ref": "#/$defs/contentIdentity"}, + "system_id": {"$ref": "#/$defs/safeId"}, + "save_kind": {"enum": ["save", "state"]}, + "compatibility_id": {"$ref": "#/$defs/safeId"}, + "backend": {"$ref": "#/$defs/backend"}, + "source_device": {"$ref": "#/$defs/sourceDevice"}, + "lineage_id": {"$ref": "#/$defs/lineageId"}, + "generation": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, + "parent_payload_sha256": {"type": "string", "pattern": "^(none|[0-9a-f]{64})$"}, + "payload": {"$ref": "#/$defs/payload"}, + "created_at": {"type": "string", "maxLength": 32, "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "software": {"$ref": "#/$defs/software"} + } + }, + "authentication": { + "type": "object", + "additionalProperties": false, + "required": ["scheme", "key_id", "key_epoch", "signed_bytes", "signature"], + "properties": { + "scheme": {"const": "ed25519-rfc8032"}, + "key_id": {"$ref": "#/$defs/keyId"}, + "key_epoch": {"type": "integer", "minimum": 1, "maximum": 2147483647}, + "signed_bytes": {"const": "rfc8785-jcs-manifest-v1"}, + "signature": {"type": "string", "pattern": "^[A-Za-z0-9_-]{86}$"} + } + } + } +} diff --git a/build/save-sync-contract.json b/build/save-sync-contract.json new file mode 100644 index 000000000..22c408dd3 --- /dev/null +++ b/build/save-sync-contract.json @@ -0,0 +1,127 @@ +{ + "schema": 1, + "contract_id": "bloomos-save-sync-contract-v1", + "bundle_schema_id": "https://bloomos.dev/schemas/save-sync-bundle-v1.json", + "limits": { + "max_manifest_bytes": 16384, + "max_payload_bytes": 67108864, + "max_generation": 9007199254740991, + "max_identifier_bytes": 160, + "max_retained_ancestry": 64 + }, + "identity": { + "game_id_authority": "bloom-game-v1", + "content_identity_authority": "bloom-content-identity", + "bundle_id_format": "bloom-save-bundle-v1:", + "bundle_id_digest": "sha256(rfc8785-jcs(manifest))", + "device_id_format": "bloom-device-v1:<32-lowercase-hex>", + "device_id_semantics": "stable-random-128-bit-identity-independent-of-key", + "lineage_id_format": "bloom-save-lineage-v1:<32-lowercase-hex>", + "lineage_id_semantics": "stable-random-128-bit-identity-per-logical-save-lineage", + "first_generation": 1, + "first_parent": "none", + "later_parent": "previous-payload-sha256", + "wall_time": "optional-advisory-only-never-orders" + }, + "authentication": { + "signature_algorithm": "ed25519-rfc8032", + "canonicalization": "rfc8785-jcs", + "signed_object": "manifest", + "signature_encoding": "base64url-no-padding", + "key_id_format": "bloom-sync-key-v1:", + "verification_order": [ + "bounds", + "schema", + "bundle-id", + "paired-device", + "active-key-epoch", + "signature", + "compatibility", + "conflict" + ] + }, + "pairing": { + "authorization": "paired-device-and-active-key-epoch-required", + "deauthorization": "reject-new-bundles-preserve-existing-user-data", + "rotation": "new-key-epoch-requires-old-and-new-signatures-or-local-physical-repair", + "cloned_device": "same-device-id-with-divergent-active-key-or-lineage-history-requires-repair", + "replay": "exact-bundle-and-payload-is-idempotent-any-id-collision-is-security-event" + }, + "invariants": { + "bundle_id": "equals-sha256-of-rfc8785-jcs-manifest", + "key_epoch": "authentication-key-epoch-equals-manifest-source-device-key-epoch", + "generation_parent": "generation-one-parent-none-otherwise-previous-payload-sha256", + "compatibility": "save-uses-save-compatibility-state-uses-state-compatibility", + "signature": "verifies-with-active-paired-key-id" + }, + "conflicts": { + "comparison_order": [ + "validity", + "authorization", + "bundle_id", + "game_and_content_identity", + "system_and_save_kind", + "compatibility_id", + "lineage_id", + "generation", + "parent_payload_sha256", + "payload_sha256" + ], + "timestamp_role": "advisory-only-never-orders", + "unknown_history": "preserve-incoming-and-require-explicit-review", + "outcomes": [ + { + "id": "new_lineage", + "condition": "no-current-bundle-for-identity-and-lineage", + "action": "stage-candidate-only" + }, + { + "id": "exact_replay", + "condition": "same-bundle-id-and-payload-sha256", + "action": "idempotent-noop" + }, + { + "id": "bundle_id_collision", + "condition": "same-bundle-id-with-different-canonical-manifest-or-payload", + "action": "reject-and-record-security-event" + }, + { + "id": "incompatible", + "condition": "game-content-system-kind-or-compatibility-mismatch", + "action": "preserve-both-and-require-explicit-review" + }, + { + "id": "fast_forward", + "condition": "same-lineage-next-generation-and-parent-equals-current-payload", + "action": "stage-candidate-only" + }, + { + "id": "stale_ancestor", + "condition": "same-lineage-older-generation-and-payload-is-retained-verified-ancestor", + "action": "idempotent-noop" + }, + { + "id": "fork", + "condition": "same-lineage-divergent-generation-or-parent", + "action": "preserve-both-and-require-explicit-review" + }, + { + "id": "history_required", + "condition": "generation-gap-or-unverified-older-generation", + "action": "preserve-incoming-and-request-contiguous-history" + }, + { + "id": "unrelated_lineage", + "condition": "same-game-save-identity-with-different-lineage", + "action": "preserve-both-and-require-explicit-review" + } + ] + }, + "isolation": { + "transport_dependency": "none", + "live_save_writes": "forbidden", + "generic_transfer_access": "read-only", + "publication_owner": "sync02-transaction-only", + "failure": "preserve-user-data-and-fail-closed" + } +} diff --git a/build/save-sync-contract.schema.json b/build/save-sync-contract.schema.json new file mode 100644 index 000000000..f5f003288 --- /dev/null +++ b/build/save-sync-contract.schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bloomos.dev/schemas/save-sync-contract-v1.json", + "title": "BloomOS save sync semantic contract", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "contract_id", + "bundle_schema_id", + "limits", + "identity", + "authentication", + "pairing", + "invariants", + "conflicts", + "isolation" + ], + "properties": { + "schema": {"const": 1}, + "contract_id": {"const": "bloomos-save-sync-contract-v1"}, + "bundle_schema_id": {"const": "https://bloomos.dev/schemas/save-sync-bundle-v1.json"}, + "limits": { + "type": "object", + "additionalProperties": false, + "required": ["max_manifest_bytes", "max_payload_bytes", "max_generation", "max_identifier_bytes", "max_retained_ancestry"], + "properties": { + "max_manifest_bytes": {"type": "integer", "minimum": 1024, "maximum": 16384}, + "max_payload_bytes": {"type": "integer", "minimum": 1, "maximum": 67108864}, + "max_generation": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, + "max_identifier_bytes": {"type": "integer", "minimum": 80, "maximum": 160}, + "max_retained_ancestry": {"type": "integer", "minimum": 1, "maximum": 64} + } + }, + "identity": {"$ref": "#/$defs/stringMap"}, + "authentication": { + "type": "object", + "additionalProperties": false, + "required": ["signature_algorithm", "canonicalization", "signed_object", "signature_encoding", "key_id_format", "verification_order"], + "properties": { + "signature_algorithm": {"type": "string", "minLength": 1}, + "canonicalization": {"type": "string", "minLength": 1}, + "signed_object": {"type": "string", "minLength": 1}, + "signature_encoding": {"type": "string", "minLength": 1}, + "key_id_format": {"type": "string", "minLength": 1}, + "verification_order": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"type": "string", "minLength": 1} + } + } + }, + "pairing": {"$ref": "#/$defs/stringMap"}, + "invariants": { + "type": "object", + "additionalProperties": false, + "required": ["bundle_id", "key_epoch", "generation_parent", "compatibility", "signature"], + "properties": { + "bundle_id": {"type": "string", "minLength": 1}, + "key_epoch": {"type": "string", "minLength": 1}, + "generation_parent": {"type": "string", "minLength": 1}, + "compatibility": {"type": "string", "minLength": 1}, + "signature": {"type": "string", "minLength": 1} + } + }, + "conflicts": { + "type": "object", + "additionalProperties": false, + "required": ["comparison_order", "timestamp_role", "unknown_history", "outcomes"], + "properties": { + "comparison_order": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"type": "string", "minLength": 1} + }, + "timestamp_role": {"type": "string", "minLength": 1}, + "unknown_history": {"type": "string", "minLength": 1}, + "outcomes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "condition", "action"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}, + "condition": {"type": "string", "minLength": 1}, + "action": {"type": "string", "minLength": 1} + } + } + } + } + }, + "isolation": {"$ref": "#/$defs/stringMap"} + }, + "$defs": { + "stringMap": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "game_id_authority": {"type": "string"}, + "content_identity_authority": {"type": "string"}, + "bundle_id_format": {"type": "string"}, + "bundle_id_digest": {"type": "string"}, + "device_id_format": {"type": "string"}, + "device_id_semantics": {"type": "string"}, + "lineage_id_format": {"type": "string"}, + "lineage_id_semantics": {"type": "string"}, + "first_generation": {"type": "integer"}, + "first_parent": {"type": "string"}, + "later_parent": {"type": "string"}, + "wall_time": {"type": "string"}, + "authorization": {"type": "string"}, + "deauthorization": {"type": "string"}, + "rotation": {"type": "string"}, + "cloned_device": {"type": "string"}, + "replay": {"type": "string"}, + "transport_dependency": {"type": "string"}, + "live_save_writes": {"type": "string"}, + "generic_transfer_access": {"type": "string"}, + "publication_owner": {"type": "string"}, + "failure": {"type": "string"} + } + } + } +} diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 97897fd00..5e381c684 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -29,6 +29,7 @@ observable properties and never substitutes for physical UX review. | Privacy | Allowlisted health/support export contains no private data | | Developer deployment | Dual-marker/update/session preflight; deterministic allowlisted manifest; exact byte/hash/mode transfer; sealed slots; atomic activation; readiness auto-rollback; active/previous overlay; bounded cleanup; ordinary network-helper activation denied | | Remote network transaction | Separate local marker; known-good station/key/update/session preflight; exact mode-0600 station snapshot in a mode-0700 JFFS2-private root with only its digest in public metadata; nonce digest; monotonic detached watchdog; begin exit cleanup armed before snapshot publication; matching partial-lease cleanup; post-state/pre-watchdog HUP/INT/TERM operation-none rollback with inactive lease, exact temporary/lock removal, unchanged configuration, and no network/service replay; one bounded mutation; guarded profile projection and explicit station restart; association/global-IPv4 deadline; corrupt-snapshot denial; apply-failure, explicit, timeout, and boot-interruption exact restoration; unqualified remote Wi-Fi disable rejected before state or radio mutation; legacy Wi-Fi-disable recovery; safe empty stale-lock recovery; transaction-bound network/boot-runtime overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | +| Save Sync contract | Closed schema-1 envelope; separate canonical GameID/content identity; exact system, save/state compatibility, backend/component, source-device, lineage, generation, parent, payload, and software identity; 16-KiB manifest and 0-to-64-MiB payload bounds; enforced bundle-digest, key-epoch, parent-generation, compatibility-kind, and active-key signature invariants; RFC 8785 canonical manifest; Ed25519 authentication; deauthorization, dual-authorized rotation, clone repair, and exact replay rules; ordered lineage/hash conflict outcomes; timestamp never orders; transport none; live-save writes forbidden; generic Saves remain read-only | ## Task-based UX certification diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3549e20fe..5987aead2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1314,6 +1314,35 @@ inventory for diagnostics and future UI consumers; restore and prune remain explicit lower-level operations and are not reachable through this read-only CLI route. +## Save Sync identity and conflict boundary + +SYNC-01 defines the transport-independent save-bundle contract in +`build/save-sync-contract.json` and the closed envelope in +`build/save-sync-bundle.schema.json`. An immutable manifest binds canonical +Bloom GameID, normalized content identity, system, save/state kind, reviewed +compatibility group, exact backend/component identity, stable source device, +lineage, generation, parent payload SHA-256, payload size/hash, and software +identity. Optional wall time is advisory and never selects a winner. +Zero-byte save payloads are retained as valid content; the manifest still binds +their exact size and the SHA-256 digest of the empty byte sequence. + +The bundle ID is the SHA-256 of RFC 8785 canonical manifest bytes. Ed25519 signs +those same bytes through a key independently versioned from the stable device +identity. Pairing requires an active key epoch; deauthorization preserves +accepted user data, ordinary rotation requires authorization by both old and +new keys, and divergent active keys under one device ID are clone evidence that +fails into explicit repair. + +Conflict classification is ordered and fail closed: exact replay is +idempotent, a contiguous parent-linked generation is a staged fast-forward, +retained verified ancestry is a stale no-op, and incompatible identities, +forks, unrelated lineages, generation gaps, or unverified history preserve both +sides for explicit review. A bundle-ID collision is a security event. No SYNC-01 +path exports, imports, transports, or writes Saves. The later `bloom-sync` +transaction is the sole planned publication owner; generic transfer keeps +Saves read-only. The complete normative contract and limitations are in +[`SAVE_SYNC.md`](SAVE_SYNC.md). + ## Canonical game identity `bloom-game-id` defines the schema-1 identity boundary. It normalizes the ROM diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index b71a4831d..d5b1c8ac2 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -25,6 +25,12 @@ Release packaging also validates `build/core-manifest.json` against every shipped libretro binary. A core change therefore cannot silently retain stale binary identity, declared license, or compatibility inventory. +The release preflight also validates the transport-independent SYNC-01 +authority, its closed save-bundle schema, the core save/state compatibility +groups, and the content/GameID authorities. This does not enable Save Sync in a +release; it prevents future runtime or transport work from silently weakening +the authenticated identity and conflict contract. + Before archive assembly, packaging evaluates `build/provenance-policy.json` for the requested channel. Stable, beta, and nightly accept only source-tier components with pinned source, license, and repository build recipes. Legacy diff --git a/docs/SAVE_SYNC.md b/docs/SAVE_SYNC.md new file mode 100644 index 000000000..311af49ca --- /dev/null +++ b/docs/SAVE_SYNC.md @@ -0,0 +1,126 @@ +# Save Sync contract + +Status: normative SYNC-01 contract. Save Sync remains unavailable until the +transport-neutral engine, a qualified transport, and the UI pass their separate +SYNC-02 through SYNC-04 gates. + +`build/save-sync-contract.json` is the semantic authority and +`build/save-sync-bundle.schema.json` is the closed bundle-envelope schema. This +package defines identity, authenticity, lineage, and conflict behavior only. It +does not export, import, transport, pair, sign, verify, or publish user saves. + +## Immutable envelope + +A version-1 bundle contains an exact `manifest`, an authentication envelope, +and a bundle ID. The opaque payload bytes are outside the JSON envelope and are +bound by the manifest's exact byte count and SHA-256 digest. A manifest is +limited to 16 KiB and a payload to 64 MiB. Zero-byte payloads remain valid so +an empty but real emulator save is not silently discarded. + +The manifest records: + +- canonical Bloom GameID and normalized content identity as separate values; +- stable system ID, `save` or `state` kind, and the applicable compatibility ID; +- exact emulator, component, and component SHA-256, plus a core identity for + Libretro; +- stable source-device ID and active key epoch; +- stable lineage ID, generation, and previous payload SHA-256; +- exact payload format, byte count, and SHA-256; +- BloomOS version and source commit; +- an optional UTC creation timestamp that is display-only evidence. + +Generation 1 uses `none` as its parent. Every later generation names the exact +payload SHA-256 of its immediate predecessor. A generation does not become +valid merely because its wall timestamp is later. + +The bundle ID is: + +```text +bloom-save-bundle-v1: +``` + +The authentication envelope is deliberately outside the manifest, so an +unchanged manifest keeps one bundle ID when its signature is re-encoded. The +signature is Ed25519 over the exact RFC 8785 JSON Canonicalization Scheme bytes +of `manifest`; it uses unpadded base64url encoding. Version 1 admits no JSON +floating-point values. See [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) +and [RFC 8032](https://www.rfc-editor.org/rfc/rfc8032). + +## Identity and compatibility + +Bloom GameID remains the path-scoped user/library identity. Content identity +remains the normalized content authority. Neither may be reconstructed from a +display title, filename, transport path, or provider metadata. + +A source device has a stable random 128-bit ID independent of its signing key. +A logical save history has a separate stable random 128-bit lineage ID. A key +ID is the SHA-256 of the raw Ed25519 public key, and the manifest and signature +envelope carry the same positive key epoch. + +`save` bundles match `save_compatibility_id`; `state` bundles match +`state_compatibility_id`. System, emulator, component identity, and compatibility +must agree with the reviewed core policy. A save adapter does not authorize a +state import, and a content match never weakens backend compatibility. + +The checked-in semantic authority makes these cross-field requirements +explicit: bundle ID equals the canonical-manifest digest; authentication and +manifest key epochs match; generation 1 has no parent and every later +generation names its previous payload; save/state kinds select their respective +compatibility groups; and the signature verifies under the active paired key +ID. The JSON Schema closes and bounds each field, while the consumer must enforce +these relationships after parsing and before conflict classification. + +## Verification and pairing + +Consumers verify in this fixed order: + +1. byte and resource bounds; +2. closed schema and version; +3. hash-derived bundle ID; +4. paired device identity; +5. active, non-revoked key epoch; +6. Ed25519 signature over canonical manifest bytes; +7. game/content/backend compatibility; +8. conflict classification. + +A bundle from an unknown, deauthorized, stale-epoch, or ambiguously cloned +device is preserved only as quarantined input and cannot publish. Deauthorization +rejects new material without deleting already accepted user data. Normal key +rotation requires authorization by both the old and new keys; loss of the old +key requires a separate local physical repair flow. Reusing one device ID with +divergent active keys or incompatible lineage history is clone evidence and +requires repair instead of silent merging. + +## Conflict outcomes + +Classification uses the ordered fields in the checked-in contract. It never +uses the optional timestamp to choose a winner. + +| Outcome | Required interpretation | +|---|---| +| `new_lineage` | No current bundle exists for this identity and lineage; stage only. | +| `exact_replay` | Bundle ID and payload digest match; idempotent no-op. | +| `bundle_id_collision` | One bundle ID names different canonical bytes or payload; reject as a security event. | +| `incompatible` | Game, content, system, kind, or compatibility differs; preserve both for review. | +| `fast_forward` | Same lineage, next generation, and parent equals the current payload; stage only. | +| `stale_ancestor` | Older generation is a retained verified ancestor; idempotent no-op. | +| `fork` | Same lineage diverges at a generation or parent; preserve both for review. | +| `history_required` | A generation gap or unverified older input needs a contiguous verified chain. | +| `unrelated_lineage` | The same save identity arrives under another lineage; preserve both for review. | + +No classification writes a live save. “Stage only” means a future SYNC-02 +engine may place verified immutable bytes in private candidate storage; it does +not mean that a transport or UI may replace user data. + +## Publication and failure boundaries + +Generic SFTP and any future Web Transfer keep Saves read-only. A transport may +move opaque bytes but never owns identity, trust, conflict, or publication. +SYNC-02 must provide one transactional owner that snapshots current data, +publishes only after explicit conflict resolution, preserves rollback material, +and never writes a live save directory directly from transport input. + +Malformed, oversized, unknown-version, unauthenticated, incompatible, forked, +gapped, or replay-collision input fails closed while preserving user data. +Diagnostics must not expose payload bytes, save names or paths, device public +keys, pairing secrets, or private lineage history. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 7249dd9dd..d485ac187 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -375,4 +375,22 @@ initial catalog intentionally supplies no RetroArch effect or bezel capability; it cannot turn opaque backgrounds or inherited overlays into trusted gameplay assets. +Save Sync trust is independent of its eventual transport. Version-1 manifests +are bounded, closed JSON objects canonicalized with RFC 8785 and authenticated +with Ed25519. Bundle IDs bind the canonical manifest; payload size and SHA-256 +bind the opaque bytes. Verification rejects unknown devices, revoked or stale +key epochs, invalid signatures, compatibility drift, and bundle-ID collisions +before conflict handling. Device identity is stable across key rotation, while +ordinary rotation requires both the old and new keys; an ambiguous clone or +lost old key requires local physical repair. Replay is idempotent only when the +bundle and payload identities match exactly. + +Lineage, generation, parent payload hash, and retained verified ancestry own +conflict semantics. Wall time is display-only and never authorizes overwrite. +Forks, unrelated lineages, gaps, unverified history, and incompatible saves are +preserved for explicit review. SYNC-01 has no runtime consumer and cannot read +or write Saves. Future transports move only immutable bundles; only the +transactional `bloom-sync` owner may eventually publish after snapshot and +conflict resolution. Generic remote access continues to expose Saves read-only. + Security-sensitive migrations and repair operations must be bounded, logged, reversible where possible, and validated before activation. diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index bcc666dbd..7f3c89645 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -2068,7 +2068,7 @@ "id": "SYNC-01", "track": "Sync", "title": "Save-bundle identity, authenticity, lineage, conflict contract", - "status": "planned", + "status": "in_progress", "dependencies": [ "META-01", "CORE-06" @@ -2077,8 +2077,10 @@ "risk_tier": 3, "deployment_tier": "host", "device_gate_required": false, - "last_audited_main": "3a7e78290285806e1ce3eb0c126887644ff665f2", - "evidence": [] + "last_audited_main": "a79d953416cd177d1927a2dd1172f3b184f5b425", + "evidence": [ + "issue #647 tracks the transport-independent SYNC-01 authority: a closed bounded bundle envelope, RFC 8785 canonical manifest identity, Ed25519 authentication, separate device/key/lineage identity, ordered hash-and-lineage conflict outcomes, and explicit no-transport/no-live-save scope" + ] }, { "id": "SYNC-02", diff --git a/test/shell/save_sync_contract.bats b/test/shell/save_sync_contract.bats new file mode 100644 index 000000000..51a37c448 --- /dev/null +++ b/test/shell/save_sync_contract.bats @@ -0,0 +1,232 @@ +#!/usr/bin/env bats + +validate() { + python3 /workspace/tools/validate_save_sync_contract.py \ + --contract "${1:-/workspace/build/save-sync-contract.json}" \ + --contract-schema "${2:-/workspace/build/save-sync-contract.schema.json}" \ + --bundle-schema "${3:-/workspace/build/save-sync-bundle.schema.json}" \ + --core-policy "${4:-/workspace/build/core-selection-policy.json}" \ + --content-policy "${5:-/workspace/build/content-identity-policy.json}" +} + +@test "SYNC-01 contract validates without a transport or device gate" { + run validate + [ "$status" -eq 0 ] + [ "$output" = "save sync contract valid: 9 outcomes, 64 MiB payload bound, transport none" ] +} + +@test "contract rejects canonicalization, signature, and verification-order drift" { + candidate="$BATS_TEST_TMPDIR/contract.json" + jq ' + .authentication.canonicalization = "locale-sorted-json" | + .authentication.signature_algorithm = "unsigned" | + .authentication.verification_order = ["conflict", "signature"] + ' /workspace/build/save-sync-contract.json >"$candidate" + + run validate "$candidate" + [ "$status" -eq 1 ] + [[ "$output" == *"canonicalization, signature, or verification order changed"* ]] +} + +@test "contract rejects cross-field invariant drift" { + candidate="$BATS_TEST_TMPDIR/contract.json" + jq ' + .invariants.key_epoch = "independent-values" | + .invariants.generation_parent = "timestamp-selected-parent" + ' /workspace/build/save-sync-contract.json >"$candidate" + + run validate "$candidate" + [ "$status" -eq 1 ] + [[ "$output" == *"cross-field bundle invariants changed"* ]] +} + +@test "contract rejects timestamp ordering and incomplete conflict outcomes" { + candidate="$BATS_TEST_TMPDIR/contract.json" + jq ' + .conflicts.timestamp_role = "newer-wins" | + .conflicts.unknown_history = "discard-older" | + .conflicts.outcomes |= map(select(.id != "fork")) + ' /workspace/build/save-sync-contract.json >"$candidate" + + run validate "$candidate" + [ "$status" -eq 1 ] + [[ "$output" == *"wall time must never choose a save winner"* ]] + [[ "$output" == *"unknown history must preserve data for explicit review"* ]] + [[ "$output" == *"conflict outcomes changed"* ]] +} + +@test "contract rejects a transport dependency or live save publication" { + candidate="$BATS_TEST_TMPDIR/contract.json" + jq ' + .isolation.transport_dependency = "syncthing" | + .isolation.live_save_writes = "allowed" | + .isolation.publication_owner = "transport" + ' /workspace/build/save-sync-contract.json >"$candidate" + + run validate "$candidate" + [ "$status" -eq 1 ] + [[ "$output" == *"transport or live-save isolation changed"* ]] +} + +@test "bundle schema requires every identity field and keeps wall time advisory" { + candidate="$BATS_TEST_TMPDIR/bundle.schema.json" + python3 - "$candidate" <<'PY' +import json +import sys + +source = "/workspace/build/save-sync-bundle.schema.json" +with open(source, encoding="utf-8") as stream: + schema = json.load(stream) +schema["$defs"]["manifest"]["required"].remove("lineage_id") +schema["$defs"]["manifest"]["required"].append("created_at") +with open(sys.argv[1], "w", encoding="utf-8", newline="\n") as stream: + json.dump(schema, stream, sort_keys=True) +PY + + run validate /workspace/build/save-sync-contract.json \ + /workspace/build/save-sync-contract.schema.json "$candidate" + [ "$status" -eq 1 ] + [[ "$output" == *"complete SYNC-01 identity set"* ]] + [[ "$output" == *"optional advisory metadata"* ]] +} + +@test "bundle schema accepts an empty save and rejects open or negative payloads" { + run python3 - <<'PY' +import copy +import json +import sys + +sys.path.insert(0, "/workspace/tools") +from validate_core_selection_policy import schema_errors + +with open("/workspace/build/save-sync-bundle.schema.json", encoding="utf-8") as stream: + schema = json.load(stream) +bundle = { + "schema": 1, + "bundle_id": "bloom-save-bundle-v1:" + "1" * 64, + "manifest": { + "schema": 1, + "bloom_game_id": "bloom-game-v1:" + "2" * 64, + "content_identity": "bloom-content:raw-file:v1:" + "3" * 64, + "system_id": "gba", + "save_kind": "save", + "compatibility_id": "gba-sram-v1", + "backend": { + "emulator_id": "libretro:mgba", + "core_id": "mgba", + "component_id": "mgba-core", + "component_sha256": "4" * 64, + }, + "source_device": { + "device_id": "bloom-device-v1:" + "5" * 32, + "key_epoch": 1, + }, + "lineage_id": "bloom-save-lineage-v1:" + "6" * 32, + "generation": 1, + "parent_payload_sha256": "none", + "payload": {"format": "bloom-save-payload-v1", "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "bytes": 0}, + "software": {"bloomos_version": "0.1.0-dev", "source_commit": "7" * 40}, + }, + "authentication": { + "scheme": "ed25519-rfc8032", + "key_id": "bloom-sync-key-v1:" + "8" * 64, + "key_epoch": 1, + "signed_bytes": "rfc8785-jcs-manifest-v1", + "signature": "A" * 86, + }, +} +assert schema_errors(bundle, schema) == [] + +invalid = copy.deepcopy(bundle) +invalid["manifest"]["transport_path"] = "/Saves/example.sav" +invalid["manifest"]["payload"]["bytes"] = -1 +failures = schema_errors(invalid, schema) +assert any("unknown fields: transport_path" in failure for failure in failures) +assert any("below the minimum" in failure for failure in failures) +print("bundle schema: closed empty-save coverage passed") +PY + [ "$status" -eq 0 ] + [ "$output" = "bundle schema: closed empty-save coverage passed" ] +} + +@test "contract rejects Bloom identity and compatibility authority drift" { + content="$BATS_TEST_TMPDIR/content.json" + core="$BATS_TEST_TMPDIR/core.json" + jq '.authority = "transport-owned"' /workspace/build/content-identity-policy.json >"$content" + jq 'del(.systems[0].candidates[0].state_compatibility_id)' \ + /workspace/build/core-selection-policy.json >"$core" + + run validate /workspace/build/save-sync-contract.json \ + /workspace/build/save-sync-contract.schema.json \ + /workspace/build/save-sync-bundle.schema.json "$core" "$content" + [ "$status" -eq 1 ] + [[ "$output" == *"does not match Bloom content/GameID policy"* ]] + [[ "$output" == *"missing bounded state_compatibility_id"* ]] +} + +@test "reference classifier uses lineage and hashes while ignoring wall time" { + run python3 - <<'PY' +import copy +import sys + +sys.path.insert(0, "/workspace/tools") +from validate_save_sync_contract import classify_relation + + +def bundle(bundle_id, generation, payload, parent, lineage="bloom-save-lineage-v1:" + "2" * 32): + return { + "bundle_id": bundle_id, + "manifest": { + "bloom_game_id": "bloom-game-v1:" + "a" * 64, + "content_identity": "bloom-content:raw-file:v1:" + "b" * 64, + "system_id": "gba", + "save_kind": "save", + "compatibility_id": "gba-sram-v1", + "lineage_id": lineage, + "generation": generation, + "parent_payload_sha256": parent, + "payload": {"sha256": payload}, + "created_at": "2026-08-31T00:00:00Z", + }, + } + + +current = bundle("bloom-save-bundle-v1:" + "1" * 64, 4, "c" * 64, "d" * 64) +replay = copy.deepcopy(current) +assert classify_relation(current, replay) == "exact_replay" + +collision = copy.deepcopy(current) +collision["manifest"]["payload"]["sha256"] = "e" * 64 +assert classify_relation(current, collision) == "bundle_id_collision" + +forward = bundle("bloom-save-bundle-v1:" + "3" * 64, 5, "f" * 64, "c" * 64) +forward["manifest"]["created_at"] = "2000-01-01T00:00:00Z" +assert classify_relation(current, forward) == "fast_forward" + +fork = copy.deepcopy(forward) +fork["bundle_id"] = "bloom-save-bundle-v1:" + "4" * 64 +fork["manifest"]["parent_payload_sha256"] = "9" * 64 +fork["manifest"]["created_at"] = "2099-01-01T00:00:00Z" +assert classify_relation(current, fork) == "fork" + +gap = bundle("bloom-save-bundle-v1:" + "5" * 64, 7, "6" * 64, "f" * 64) +assert classify_relation(current, gap) == "history_required" + +ancestor = bundle("bloom-save-bundle-v1:" + "7" * 64, 2, "8" * 64, "0" * 64) +assert classify_relation(current, ancestor, ["8" * 64]) == "stale_ancestor" + +other = bundle("bloom-save-bundle-v1:" + "9" * 64, 1, "0" * 64, "none", + "bloom-save-lineage-v1:" + "a" * 32) +assert classify_relation(current, other) == "unrelated_lineage" + +incompatible = copy.deepcopy(forward) +incompatible["bundle_id"] = "bloom-save-bundle-v1:" + "b" * 64 +incompatible["manifest"]["compatibility_id"] = "gba-other-v1" +assert classify_relation(current, incompatible) == "incompatible" + +assert classify_relation(None, current) == "new_lineage" +print("reference conflict classifier: 9 outcomes covered") +PY + [ "$status" -eq 0 ] + [ "$output" = "reference conflict classifier: 9 outcomes covered" ] +} diff --git a/test/test_ci_scope.sh b/test/test_ci_scope.sh index 31b45916f..6ce31a102 100755 --- a/test/test_ci_scope.sh +++ b/test/test_ci_scope.sh @@ -132,6 +132,16 @@ expect "none" static-analysis "build/save-migration-policy.schema.json tools/validate_save_migration_policy.py" expect "none" build "build/save-migration-policy.schema.json tools/validate_save_migration_policy.py" +expect "none" static-analysis "build/save-sync-contract.json +build/save-sync-contract.schema.json +build/save-sync-bundle.schema.json +tools/validate_save_sync_contract.py +test/shell/save_sync_contract.bats" +expect "none" build "build/save-sync-contract.json +build/save-sync-contract.schema.json +build/save-sync-bundle.schema.json +tools/validate_save_sync_contract.py +test/shell/save_sync_contract.bats" expect "full" build "src/common/config.mk" expect "full" build "build/shared-libs/build.sh" expect "full" static-analysis "Makefile" @@ -259,6 +269,15 @@ expect "1" risk "build/core-inventory-disposition.schema.json tools/validate_core_inventory_disposition.py" expect "2" risk "build/save-migration-policy.schema.json tools/validate_save_migration_policy.py" +expect "3" risk "build/save-sync-contract.json +build/save-sync-contract.schema.json +build/save-sync-bundle.schema.json +tools/validate_save_sync_contract.py +test/shell/save_sync_contract.bats" +expect "full" native-tests "build/save-sync-contract.json +tools/validate_save_sync_contract.py" +expect "full" shell-tests "build/save-sync-bundle.schema.json +test/shell/save_sync_contract.bats" expect "/workspace/test/shell/bloom_device.bats /workspace/test/shell/dev_deploy.bats" shell-tests "test/shell/bloom_device.bats test/shell/dev_deploy.bats" expect "full" shell-tests "static/build/.tmp_update/bin/bloom-dev-deploy" diff --git a/tools/ci-scope.sh b/tools/ci-scope.sh index c6109e906..08ceed22e 100755 --- a/tools/ci-scope.sh +++ b/tools/ci-scope.sh @@ -153,6 +153,10 @@ while IFS= read -r path; do build/content-identity-policy.json|tools/validate_content_identity_policy.py|\ test/shell/content_identity_policy.bats) ;; + build/save-sync-contract.json|build/save-sync-contract.schema.json|\ + build/save-sync-bundle.schema.json|tools/validate_save_sync_contract.py|\ + test/shell/save_sync_contract.bats) + ;; build/libretro-artwork-policy.json|tools/validate_libretro_artwork_policy.py|\ test/shell/libretro_artwork_policy.bats) ;; @@ -490,6 +494,12 @@ while IFS= read -r path; do add_shell_target /workspace/test/shell/save_migration_policy.bats add_shell_target /workspace/test/shell/save_migration.bats ;; + build/save-sync-contract.json|build/save-sync-contract.schema.json|\ + build/save-sync-bundle.schema.json|tools/validate_save_sync_contract.py|\ + test/shell/save_sync_contract.bats) + raise_risk 3 + add_shell_target /workspace/test/shell/save_sync_contract.bats + ;; build/transfer-policy*.json|tools/validate_transfer_policy.py|\ static/build/.tmp_update/bin/bloom-transfer-policy) raise_risk 2 diff --git a/tools/validate_save_sync_contract.py b/tools/validate_save_sync_contract.py new file mode 100644 index 000000000..ee9ae2001 --- /dev/null +++ b/tools/validate_save_sync_contract.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Validate the transport-independent BloomOS save-sync contract.""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import sys +from typing import Any, Dict, Iterable, List, Optional + +from validate_core_selection_policy import schema_errors + + +EXPECTED_LIMITS = { + "max_manifest_bytes": 16384, + "max_payload_bytes": 67108864, + "max_generation": 9007199254740991, + "max_identifier_bytes": 160, + "max_retained_ancestry": 64, +} +EXPECTED_IDENTITY = { + "game_id_authority": "bloom-game-v1", + "content_identity_authority": "bloom-content-identity", + "bundle_id_format": "bloom-save-bundle-v1:", + "bundle_id_digest": "sha256(rfc8785-jcs(manifest))", + "device_id_format": "bloom-device-v1:<32-lowercase-hex>", + "device_id_semantics": "stable-random-128-bit-identity-independent-of-key", + "lineage_id_format": "bloom-save-lineage-v1:<32-lowercase-hex>", + "lineage_id_semantics": "stable-random-128-bit-identity-per-logical-save-lineage", + "first_generation": 1, + "first_parent": "none", + "later_parent": "previous-payload-sha256", + "wall_time": "optional-advisory-only-never-orders", +} +EXPECTED_AUTHENTICATION = { + "signature_algorithm": "ed25519-rfc8032", + "canonicalization": "rfc8785-jcs", + "signed_object": "manifest", + "signature_encoding": "base64url-no-padding", + "key_id_format": "bloom-sync-key-v1:", + "verification_order": [ + "bounds", + "schema", + "bundle-id", + "paired-device", + "active-key-epoch", + "signature", + "compatibility", + "conflict", + ], +} +EXPECTED_PAIRING = { + "authorization": "paired-device-and-active-key-epoch-required", + "deauthorization": "reject-new-bundles-preserve-existing-user-data", + "rotation": "new-key-epoch-requires-old-and-new-signatures-or-local-physical-repair", + "cloned_device": "same-device-id-with-divergent-active-key-or-lineage-history-requires-repair", + "replay": "exact-bundle-and-payload-is-idempotent-any-id-collision-is-security-event", +} +EXPECTED_INVARIANTS = { + "bundle_id": "equals-sha256-of-rfc8785-jcs-manifest", + "key_epoch": "authentication-key-epoch-equals-manifest-source-device-key-epoch", + "generation_parent": "generation-one-parent-none-otherwise-previous-payload-sha256", + "compatibility": "save-uses-save-compatibility-state-uses-state-compatibility", + "signature": "verifies-with-active-paired-key-id", +} +EXPECTED_COMPARISON_ORDER = [ + "validity", + "authorization", + "bundle_id", + "game_and_content_identity", + "system_and_save_kind", + "compatibility_id", + "lineage_id", + "generation", + "parent_payload_sha256", + "payload_sha256", +] +EXPECTED_OUTCOMES = [ + { + "id": "new_lineage", + "condition": "no-current-bundle-for-identity-and-lineage", + "action": "stage-candidate-only", + }, + { + "id": "exact_replay", + "condition": "same-bundle-id-and-payload-sha256", + "action": "idempotent-noop", + }, + { + "id": "bundle_id_collision", + "condition": "same-bundle-id-with-different-canonical-manifest-or-payload", + "action": "reject-and-record-security-event", + }, + { + "id": "incompatible", + "condition": "game-content-system-kind-or-compatibility-mismatch", + "action": "preserve-both-and-require-explicit-review", + }, + { + "id": "fast_forward", + "condition": "same-lineage-next-generation-and-parent-equals-current-payload", + "action": "stage-candidate-only", + }, + { + "id": "stale_ancestor", + "condition": "same-lineage-older-generation-and-payload-is-retained-verified-ancestor", + "action": "idempotent-noop", + }, + { + "id": "fork", + "condition": "same-lineage-divergent-generation-or-parent", + "action": "preserve-both-and-require-explicit-review", + }, + { + "id": "history_required", + "condition": "generation-gap-or-unverified-older-generation", + "action": "preserve-incoming-and-request-contiguous-history", + }, + { + "id": "unrelated_lineage", + "condition": "same-game-save-identity-with-different-lineage", + "action": "preserve-both-and-require-explicit-review", + }, +] +EXPECTED_ISOLATION = { + "transport_dependency": "none", + "live_save_writes": "forbidden", + "generic_transfer_access": "read-only", + "publication_owner": "sync02-transaction-only", + "failure": "preserve-user-data-and-fail-closed", +} +EXPECTED_MANIFEST_FIELDS = { + "schema", + "bloom_game_id", + "content_identity", + "system_id", + "save_kind", + "compatibility_id", + "backend", + "source_device", + "lineage_id", + "generation", + "parent_payload_sha256", + "payload", + "software", +} +EMULATOR_ID = re.compile(r"^(?:libretro|standalone):[a-z0-9][a-z0-9._-]*$") +COMPATIBILITY_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +CONFLICT_IDENTITY_FIELDS = ( + "bloom_game_id", "content_identity", "system_id", "save_kind", "compatibility_id" +) + + +def load(path: pathlib.Path) -> Dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError(f"cannot read {path}: {error}") from error + if not isinstance(value, dict): + raise ValueError(f"{path} must contain a JSON object") + return value + + +def resolve(schema: Dict[str, Any], reference: str) -> Dict[str, Any]: + value: Any = schema + relative = reference[2:] if reference.startswith("#/") else reference + for component in relative.split("/"): + value = value[component.replace("~1", "/").replace("~0", "~")] + if not isinstance(value, dict): + raise ValueError(f"schema reference does not resolve to an object: {reference}") + return value + + +def referenced(schema: Dict[str, Any], rule: Dict[str, Any]) -> Dict[str, Any]: + reference = rule.get("$ref") + return resolve(schema, reference) if isinstance(reference, str) else rule + + +def require_exact(actual: Any, expected: Any, message: str, errors: List[str]) -> None: + if actual != expected: + errors.append(message) + + +def walk_schema(value: Any) -> Iterable[Dict[str, Any]]: + if isinstance(value, dict): + yield value + for child in value.values(): + yield from walk_schema(child) + elif isinstance(value, list): + for child in value: + yield from walk_schema(child) + + +def validate_bundle_schema(bundle_schema: Dict[str, Any], errors: List[str]) -> None: + if bundle_schema.get("$id") != "https://bloomos.dev/schemas/save-sync-bundle-v1.json": + errors.append("unknown save-sync bundle schema identity") + return + if bundle_schema.get("additionalProperties") is not False or set(bundle_schema.get("required", [])) != { + "schema", "bundle_id", "manifest", "authentication" + }: + errors.append("bundle envelope must be exact and closed") + properties = bundle_schema.get("properties", {}) + try: + manifest = referenced(bundle_schema, properties["manifest"]) + authentication = referenced(bundle_schema, properties["authentication"]) + payload = resolve(bundle_schema, "#/$defs/payload") + except (KeyError, TypeError, ValueError) as error: + errors.append(f"bundle schema is missing a required definition: {error}") + return + if manifest.get("additionalProperties") is not False: + errors.append("save manifest must reject unknown fields") + if set(manifest.get("required", [])) != EXPECTED_MANIFEST_FIELDS: + errors.append("save manifest does not require the complete SYNC-01 identity set") + manifest_properties = manifest.get("properties", {}) + if "created_at" not in manifest_properties or "created_at" in manifest.get("required", []): + errors.append("wall timestamp must exist only as optional advisory metadata") + if authentication.get("additionalProperties") is not False: + errors.append("authentication envelope must reject unknown fields") + auth_properties = authentication.get("properties", {}) + if auth_properties.get("scheme", {}).get("const") != "ed25519-rfc8032" or \ + auth_properties.get("signed_bytes", {}).get("const") != "rfc8785-jcs-manifest-v1": + errors.append("bundle schema must bind Ed25519 to RFC 8785 manifest bytes") + payload_bytes = payload.get("properties", {}).get("bytes", {}) + generation = manifest_properties.get("generation", {}) + if payload_bytes.get("minimum") != 0 or \ + payload_bytes.get("maximum") != EXPECTED_LIMITS["max_payload_bytes"] or \ + generation.get("maximum") != EXPECTED_LIMITS["max_generation"]: + errors.append("bundle schema limits drift from the semantic contract") + parent_pattern = manifest_properties.get("parent_payload_sha256", {}).get("pattern") + if parent_pattern != "^(none|[0-9a-f]{64})$": + errors.append("generation parent must be none or an exact lowercase SHA-256") + for rule in walk_schema(bundle_schema): + if rule.get("type") == "number": + errors.append("save-sync schemas must not admit floating-point values") + break + + +def validate_authorities( + core_policy: Dict[str, Any], content_policy: Dict[str, Any], errors: List[str]) -> None: + if content_policy.get("schema") != 1 or \ + content_policy.get("authority") != EXPECTED_IDENTITY["content_identity_authority"] or \ + content_policy.get("game_id_contract", {}).get("authority") != EXPECTED_IDENTITY["game_id_authority"]: + errors.append("save-sync identity authority does not match Bloom content/GameID policy") + systems = core_policy.get("systems") + if not isinstance(systems, list) or not systems: + errors.append("core policy must provide bounded system compatibility authority") + return + for system_index, system in enumerate(systems): + if not isinstance(system, dict): + errors.append(f"core policy system[{system_index}] is invalid") + continue + system_id = system.get("id", f"system[{system_index}]") + candidates = system.get("candidates") + if not isinstance(candidates, list) or not candidates: + errors.append(f"{system_id}: save-sync requires at least one bounded core candidate") + continue + for candidate_index, candidate in enumerate(candidates): + label = f"{system_id}.candidate[{candidate_index}]" + if not isinstance(candidate, dict) or EMULATOR_ID.fullmatch(str(candidate.get("emulator_id", ""))) is None: + errors.append(f"{label}: invalid emulator identity") + continue + for field in ("save_compatibility_id", "state_compatibility_id"): + value = candidate.get(field) + if not isinstance(value, str) or COMPATIBILITY_ID.fullmatch(value) is None: + errors.append(f"{label}: missing bounded {field}") + + +def classify_relation( + current: Optional[Dict[str, Any]], incoming: Dict[str, Any], + retained_ancestor_payloads: Iterable[str] = ()) -> str: + """Reference SYNC-01 conflict classifier after validation/authentication. + + This function does not verify schemas, hashes, pairing, or signatures and + never reads or writes payload bytes. Callers must complete the contract's + preflight before using the returned relation. + """ + incoming_manifest = incoming.get("manifest") + if not isinstance(incoming_manifest, dict): + raise ValueError("incoming bundle lacks a validated manifest") + incoming_payload = incoming_manifest.get("payload") + if not isinstance(incoming_payload, dict) or not isinstance(incoming_payload.get("sha256"), str): + raise ValueError("incoming bundle lacks a validated payload identity") + if current is None: + return "new_lineage" + current_manifest = current.get("manifest") + if not isinstance(current_manifest, dict): + raise ValueError("current bundle lacks a validated manifest") + current_payload = current_manifest.get("payload") + if not isinstance(current_payload, dict) or not isinstance(current_payload.get("sha256"), str): + raise ValueError("current bundle lacks a validated payload identity") + if current.get("bundle_id") == incoming.get("bundle_id"): + return "exact_replay" if current == incoming else "bundle_id_collision" + if any(current_manifest.get(field) != incoming_manifest.get(field) + for field in CONFLICT_IDENTITY_FIELDS): + return "incompatible" + if current_manifest.get("lineage_id") != incoming_manifest.get("lineage_id"): + return "unrelated_lineage" + current_generation = current_manifest.get("generation") + incoming_generation = incoming_manifest.get("generation") + if not isinstance(current_generation, int) or isinstance(current_generation, bool) or \ + not isinstance(incoming_generation, int) or isinstance(incoming_generation, bool): + raise ValueError("bundle generation is not a validated integer") + current_sha = current_payload["sha256"] + incoming_sha = incoming_payload["sha256"] + if incoming_generation == current_generation + 1: + return "fast_forward" if incoming_manifest.get("parent_payload_sha256") == current_sha else "fork" + if incoming_generation == current_generation: + return "fork" + if incoming_generation < current_generation: + return "stale_ancestor" if incoming_sha in set(retained_ancestor_payloads) else "history_required" + return "history_required" + + +def validate( + contract: Dict[str, Any], contract_schema: Dict[str, Any], + bundle_schema: Dict[str, Any], core_policy: Dict[str, Any], + content_policy: Dict[str, Any]) -> List[str]: + errors: List[str] = [] + if contract_schema.get("$id") != "https://bloomos.dev/schemas/save-sync-contract-v1.json": + errors.append("unknown save-sync contract schema identity") + errors.extend("contract schema: " + failure for failure in schema_errors(contract, contract_schema)) + if contract.get("schema") != 1 or contract.get("contract_id") != "bloomos-save-sync-contract-v1" or \ + contract.get("bundle_schema_id") != "https://bloomos.dev/schemas/save-sync-bundle-v1.json": + errors.append("unsupported save-sync contract identity") + require_exact(contract.get("limits"), EXPECTED_LIMITS, "version-1 resource limits changed", errors) + require_exact(contract.get("identity"), EXPECTED_IDENTITY, "identity or lineage contract changed", errors) + require_exact( + contract.get("authentication"), EXPECTED_AUTHENTICATION, + "canonicalization, signature, or verification order changed", errors) + require_exact(contract.get("pairing"), EXPECTED_PAIRING, "pairing or key lifecycle contract changed", errors) + require_exact( + contract.get("invariants"), EXPECTED_INVARIANTS, + "cross-field bundle invariants changed", errors) + conflicts = contract.get("conflicts") + if not isinstance(conflicts, dict): + errors.append("conflict contract must be an object") + else: + require_exact( + conflicts.get("comparison_order"), EXPECTED_COMPARISON_ORDER, + "conflict comparison order changed", errors) + if conflicts.get("timestamp_role") != "advisory-only-never-orders": + errors.append("wall time must never choose a save winner") + if conflicts.get("unknown_history") != "preserve-incoming-and-require-explicit-review": + errors.append("unknown history must preserve data for explicit review") + require_exact(conflicts.get("outcomes"), EXPECTED_OUTCOMES, "conflict outcomes changed", errors) + require_exact(contract.get("isolation"), EXPECTED_ISOLATION, "transport or live-save isolation changed", errors) + validate_bundle_schema(bundle_schema, errors) + validate_authorities(core_policy, content_policy, errors) + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--contract", type=pathlib.Path, required=True) + parser.add_argument("--contract-schema", type=pathlib.Path, required=True) + parser.add_argument("--bundle-schema", type=pathlib.Path, required=True) + parser.add_argument("--core-policy", type=pathlib.Path, required=True) + parser.add_argument("--content-policy", type=pathlib.Path, required=True) + args = parser.parse_args() + try: + errors = validate( + load(args.contract), load(args.contract_schema), load(args.bundle_schema), + load(args.core_policy), load(args.content_policy)) + except ValueError as error: + errors = [str(error)] + if errors: + for error in errors: + print(f"save sync contract invalid: {error}", file=sys.stderr) + return 1 + print("save sync contract valid: 9 outcomes, 64 MiB payload bound, transport none") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b63ed60bdbe020e71e97687141c40ffd4c3b2ee3 Mon Sep 17 00:00:00 2001 From: boburning Date: Mon, 31 Aug 2026 13:33:37 -0400 Subject: [PATCH 2/2] docs(program): publish SYNC-01 PR evidence --- docs/program/BloomOS_Work_Program_v3.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 7f3c89645..0f230e339 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -2073,13 +2073,14 @@ "META-01", "CORE-06" ], - "github_prs": [], + "github_prs": [648], "risk_tier": 3, "deployment_tier": "host", "device_gate_required": false, "last_audited_main": "a79d953416cd177d1927a2dd1172f3b184f5b425", "evidence": [ - "issue #647 tracks the transport-independent SYNC-01 authority: a closed bounded bundle envelope, RFC 8785 canonical manifest identity, Ed25519 authentication, separate device/key/lineage identity, ordered hash-and-lineage conflict outcomes, and explicit no-transport/no-live-save scope" + "issue #647 tracks the transport-independent SYNC-01 authority: a closed bounded bundle envelope, RFC 8785 canonical manifest identity, Ed25519 authentication, separate device/key/lineage identity, ordered hash-and-lineage conflict outcomes, and explicit no-transport/no-live-save scope", + "PR #648 publishes the reviewed SYNC-01 contract from exact main a79d953416cd177d1927a2dd1172f3b184f5b425; focused 9/9, native 595/595, ASan 595/595, shell 830/830, deterministic UI 10/10, bounded static analysis, and pinned-toolchain validator gates pass; clean Linux CI owns the full ARM gate" ] }, {