Add unmounted schema commitments and v2 provisioning wire primitives
13 files changed,
+2133
−0
internal/home/admission_profiles/credential-current-v1.json+191 −0internal/home/admission_profiles/directory-current-v1.json+391 −0internal/home/admission_profiles/directory-historical-empty-v1.json+506 −0internal/home/schema_commitment_linux.go+248 −0internal/home/schema_commitment_linux_test.go+294 −0internal/operator/schema_command_linux.go+23 −0internal/operator/schema_command_linux_test.go+37 −0internal/operator/schema_protocol.go+162 −0internal/operator/schema_protocol_test.go+188 −0internal/operator/testdata/schema-v1-authorization.json+1 −0internal/operator/testdata/schema-v1-inspection.json+1 −0internal/provisioner/schema_plan_linux.go+36 −0internal/provisioner/schema_plan_linux_test.go+55 −0
CI passed — run details
diff --git a/internal/home/admission_profiles/credential-current-v1.json b/internal/home/admission_profiles/credential-current-v1.json| new file mode 100644 |
| index 0000000..12508e9 |
| --- /dev/null |
| +++ b/internal/home/admission_profiles/credential-current-v1.json |
| @@ -0,0 +1,191 @@ |
| +{ |
| + "version": 1, |
| + "id": "credential-current-v1", |
| + "role": "credential", |
| + "validation_policy": 1, |
| + "migrations": [ |
| + { |
| + "id": "home/0001_people", |
| + "checksum": "5e65372f0796be99d6fcbba09c97b92ed36a5faf1bf36a53be6f5caf0771eb9b" |
| + }, |
| + { |
| + "id": "home/0002_signin_links", |
| + "checksum": "11429f3a12f605f0e24f890952a2831a44d8e5030d10471c7a5c1c3672e630a7" |
| + }, |
| + { |
| + "id": "oficina_credential_operator/0001_operator", |
| + "checksum": "102312b9494947e28b58710e47081a6d6d601812217146e5a241c2b9b5c1471e" |
| + }, |
| + { |
| + "id": "oficina_credential_operator/0002_operator_key_identity", |
| + "checksum": "4a5a57d70bb06c7411706b98267ecebe341e55e6252035c44428e47c2c6cad6b" |
| + }, |
| + { |
| + "id": "passkey/0001_init", |
| + "checksum": "56d5073a880c3b1b8330654873b13cbd42a72596138499380b5a013e4d43196e" |
| + }, |
| + { |
| + "id": "sessions/0001_init", |
| + "checksum": "50bbfc92cfcc708b09b672b71f24da789ae6065f426c9db7af61befa90098bbb" |
| + } |
| + ], |
| + "objects": [ |
| + { |
| + "Type": "index", |
| + "Name": "idx_signin_links_expires_at", |
| + "Table": "signin_links", |
| + "SQL": "CREATE INDEX idx_signin_links_expires_at ON signin_links(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "passkey_credentials_subject", |
| + "Table": "passkey_credentials", |
| + "SQL": "CREATE INDEX passkey_credentials_subject\n ON passkey_credentials (subject)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "passkey_recovery_codes_subject", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "CREATE INDEX passkey_recovery_codes_subject\n ON passkey_recovery_codes (subject)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_credential_operator_results_1", |
| + "Table": "credential_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_credential_operator_results_2", |
| + "Table": "credential_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_challenges_1", |
| + "Table": "passkey_challenges", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_credentials_1", |
| + "Table": "passkey_credentials", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_pending_1", |
| + "Table": "passkey_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_recovery_codes_1", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_people_1", |
| + "Table": "people", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_people_2", |
| + "Table": "people", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_rastrillo_migrations_1", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_sessions_1", |
| + "Table": "sessions", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_signin_links_1", |
| + "Table": "signin_links", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_lock", |
| + "Table": "_litestream_lock", |
| + "SQL": "CREATE TABLE _litestream_lock (id INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_seq", |
| + "Table": "_litestream_seq", |
| + "SQL": "CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "credential_operator_results", |
| + "Table": "credential_operator_results", |
| + "SQL": "CREATE TABLE credential_operator_results (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n action TEXT NOT NULL,\n sequence INTEGER NOT NULL UNIQUE CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n authorization_head TEXT NOT NULL CHECK(length(authorization_head)=43),\n request_mac BLOB NOT NULL CHECK(typeof(request_mac)='blob' AND length(request_mac)=32),\n sealed_result BLOB NOT NULL CHECK(typeof(sealed_result)='blob' AND length(sealed_result) BETWEEN 29 AND 49180),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, operator_ref TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "credential_operator_state", |
| + "Table": "credential_operator_state", |
| + "SQL": "CREATE TABLE credential_operator_state (\n singleton INTEGER PRIMARY KEY CHECK(singleton=1),\n origin TEXT NOT NULL,\n installation TEXT NOT NULL,\n registry TEXT NOT NULL,\n device TEXT NOT NULL,\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n head TEXT NOT NULL CHECK(length(head)=43),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, key_id TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_challenges", |
| + "Table": "passkey_challenges", |
| + "SQL": "CREATE TABLE passkey_challenges (\n challenge TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n purpose TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_credentials", |
| + "Table": "passkey_credentials", |
| + "SQL": "CREATE TABLE passkey_credentials (\n id TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n public_key BLOB NOT NULL,\n sign_count INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_pending", |
| + "Table": "passkey_pending", |
| + "SQL": "CREATE TABLE passkey_pending (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n return_to TEXT NOT NULL DEFAULT '',\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_recovery_codes", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "CREATE TABLE passkey_recovery_codes (\n code_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "people", |
| + "Table": "people", |
| + "SQL": "CREATE TABLE people (\n person_ref TEXT PRIMARY KEY,\n blind_index TEXT NOT NULL UNIQUE, -- UNIQUE already indexes it; no second index\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "rastrillo_migrations", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "CREATE TABLE rastrillo_migrations (\n id TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL,\n checksum TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "sessions", |
| + "Table": "sessions", |
| + "SQL": "CREATE TABLE sessions (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n auth_time TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "signin_links", |
| + "Table": "signin_links", |
| + "SQL": "CREATE TABLE signin_links (\n token_hash TEXT PRIMARY KEY,\n sealed_address BLOB NOT NULL,\n purpose TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + } |
| + ], |
| + "empty_tables": [] |
| +} |
diff --git a/internal/home/admission_profiles/directory-current-v1.json b/internal/home/admission_profiles/directory-current-v1.json| new file mode 100644 |
| index 0000000..4bd396d |
| --- /dev/null |
| +++ b/internal/home/admission_profiles/directory-current-v1.json |
| @@ -0,0 +1,391 @@ |
| +{ |
| + "version": 1, |
| + "id": "directory-current-v1", |
| + "role": "directory", |
| + "validation_policy": 1, |
| + "migrations": [ |
| + { |
| + "id": "oficina_directory/0001_directory", |
| + "checksum": "d21fb3a2c8021c25110293d1fb7d3cad7391f020e33d3baba5b4bb2a4286d122" |
| + }, |
| + { |
| + "id": "oficina_directory_operator/0001_operator", |
| + "checksum": "c2b659069da4459aa79cdd89160656d3b850474b59bfb01670ad477165055956" |
| + }, |
| + { |
| + "id": "oficina_directory_operator/0002_owner_receipts", |
| + "checksum": "b916fd5db6c9a69cff4904f6d66fad8aedba12163520c6e42351561d8c1b2a5a" |
| + }, |
| + { |
| + "id": "oficina_directory_operator/0003_operator_key_identity", |
| + "checksum": "56f2a3966f9752f46773c2eba0cc8fb74114d87b169728c4ef68b63a665eb8b9" |
| + }, |
| + { |
| + "id": "oficina_directory_operator_bindings/0001_operator_bindings", |
| + "checksum": "0cfda439adfbb909a9b2bc88e27fb4d148ae1e60997faaf3ef130d42610f8136" |
| + }, |
| + { |
| + "id": "oficina_directory_roster/0001_roster", |
| + "checksum": "cae647391de9529a39488706e123cab70392ab104b4698b5092d08ec6d7ced6c" |
| + }, |
| + { |
| + "id": "oficina_handoff/0001_exchange", |
| + "checksum": "d2b6b9c416a34c3711e4959b0a05fdd13b80bc2f68b20146210c14d67fa1564e" |
| + }, |
| + { |
| + "id": "sessions/0001_init", |
| + "checksum": "50bbfc92cfcc708b09b672b71f24da789ae6065f426c9db7af61befa90098bbb" |
| + } |
| + ], |
| + "objects": [ |
| + { |
| + "Type": "index", |
| + "Name": "memberships_person", |
| + "Table": "memberships", |
| + "SQL": "CREATE INDEX memberships_person ON memberships(person_ref, team_id)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_inbox_expiry", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "CREATE INDEX oficina_handoff_inbox_expiry ON oficina_handoff_inbox(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_pending_expiry", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "CREATE INDEX oficina_handoff_pending_expiry ON oficina_handoff_pending(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_replay_expiry", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "CREATE INDEX oficina_handoff_replay_expiry ON oficina_handoff_replay(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_consumer_records_1", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_consumer_records_2", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_1", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_2", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_3", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_results_1", |
| + "Table": "directory_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_results_2", |
| + "Table": "directory_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_memberships_1", |
| + "Table": "memberships", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_inbox_1", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_inbox_2", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_pending_1", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_pending_2", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_replay_1", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_rastrillo_migrations_1", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_consumers_1", |
| + "Table": "roster_consumers", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_operator_receipts_1", |
| + "Table": "roster_operator_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_tokens_1", |
| + "Table": "roster_tokens", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_tokens_2", |
| + "Table": "roster_tokens", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_sessions_1", |
| + "Table": "sessions", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_1", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_2", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_3", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_teams_1", |
| + "Table": "teams", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_teams_2", |
| + "Table": "teams", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_active_1", |
| + "Table": "verification_active", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_active_2", |
| + "Table": "verification_active", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_attempts_1", |
| + "Table": "verification_attempts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_outcomes_1", |
| + "Table": "verification_outcomes", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_lock", |
| + "Table": "_litestream_lock", |
| + "SQL": "CREATE TABLE _litestream_lock (id INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_seq", |
| + "Table": "_litestream_seq", |
| + "SQL": "CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_consumer_records", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "CREATE TABLE directory_operator_consumer_records (\n consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),\n operation_id TEXT NOT NULL UNIQUE REFERENCES roster_operator_receipts(operation_id),\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n generation TEXT NOT NULL CHECK(length(generation)=43),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_owner_receipts", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "CREATE TABLE directory_operator_owner_receipts (\n issuer TEXT NOT NULL,\n owner_operation_id TEXT NOT NULL CHECK(length(owner_operation_id)=43),\n directory_operation_id TEXT NOT NULL UNIQUE REFERENCES directory_operator_results(operation_id) DEFERRABLE INITIALLY DEFERRED,\n team_id INTEGER NOT NULL UNIQUE REFERENCES teams(id),\n PRIMARY KEY(issuer,owner_operation_id)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_results", |
| + "Table": "directory_operator_results", |
| + "SQL": "CREATE TABLE directory_operator_results (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n action TEXT NOT NULL,\n sequence INTEGER NOT NULL UNIQUE CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n authorization_head TEXT NOT NULL CHECK(length(authorization_head)=43),\n request_mac BLOB NOT NULL CHECK(typeof(request_mac)='blob' AND length(request_mac)=32),\n sealed_result BLOB NOT NULL CHECK(typeof(sealed_result)='blob' AND length(sealed_result) BETWEEN 29 AND 49180),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, operator_ref TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_state", |
| + "Table": "directory_operator_state", |
| + "SQL": "CREATE TABLE directory_operator_state (\n singleton INTEGER PRIMARY KEY CHECK(singleton=1),\n origin TEXT NOT NULL,\n installation TEXT NOT NULL,\n registry TEXT NOT NULL,\n device TEXT NOT NULL,\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n head TEXT NOT NULL CHECK(length(head)=43),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, key_id TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "memberships", |
| + "Table": "memberships", |
| + "SQL": "CREATE TABLE memberships (\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n person_ref TEXT NOT NULL CHECK(length(person_ref) = 29 AND substr(person_ref,1,3) = 'pr_' AND substr(person_ref,4) NOT GLOB '*[^a-z2-7]*'),\n role TEXT NOT NULL CHECK(role IN ('owner', 'member')),\n PRIMARY KEY(team_id, person_ref)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_clock", |
| + "Table": "oficina_handoff_clock", |
| + "SQL": "CREATE TABLE oficina_handoff_clock (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n observed INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_inbox", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "CREATE TABLE oficina_handoff_inbox (\n handle_hash TEXT PRIMARY KEY,\n jti_hash TEXT NOT NULL UNIQUE,\n sealed_token BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_pending", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "CREATE TABLE oficina_handoff_pending (\n secret_hash TEXT PRIMARY KEY,\n nonce_hash TEXT NOT NULL UNIQUE,\n sealed_path BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_replay", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "CREATE TABLE oficina_handoff_replay (\n jti_hash TEXT PRIMARY KEY,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "rastrillo_migrations", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "CREATE TABLE rastrillo_migrations (\n id TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL,\n checksum TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_consumers", |
| + "Table": "roster_consumers", |
| + "SQL": "CREATE TABLE roster_consumers (\n consumer_id TEXT PRIMARY KEY REFERENCES team_instances(consumer_id) ON DELETE RESTRICT,\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n app TEXT NOT NULL,\n origin TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK(enabled IN (0,1)),\n FOREIGN KEY(team_id,app) REFERENCES team_instances(team_id,app) ON DELETE RESTRICT\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_generation", |
| + "Table": "roster_generation", |
| + "SQL": "CREATE TABLE roster_generation (\n singleton INTEGER PRIMARY KEY CHECK(singleton = 1),\n generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*')\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_operator_receipts", |
| + "Table": "roster_operator_receipts", |
| + "SQL": "CREATE TABLE roster_operator_receipts (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id) BETWEEN 1 AND 128 AND operation_id NOT GLOB '*[^a-zA-Z0-9_-]*'),\n sequence INTEGER NOT NULL CHECK(typeof(sequence) = 'integer' AND sequence \u003e 0),\n operation TEXT NOT NULL CHECK(operation IN ('bootstrap','provision','rotate','disable','restore')),\n consumer_id TEXT CHECK(consumer_id IS NULL OR (length(consumer_id) BETWEEN 1 AND 128 AND consumer_id NOT GLOB '*[^a-zA-Z0-9_-]*')),\n generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*'),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest) = 'blob' AND length(record_digest) = 32),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at) = 'integer' AND completed_at \u003e 0)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_tokens", |
| + "Table": "roster_tokens", |
| + "SQL": "CREATE TABLE roster_tokens (\n token_hash BLOB PRIMARY KEY CHECK(typeof(token_hash) = 'blob' AND length(token_hash) = 32),\n consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id) ON DELETE RESTRICT,\n slot TEXT NOT NULL CHECK(slot IN ('current','previous')),\n issued_at INTEGER NOT NULL CHECK(typeof(issued_at) = 'integer' AND issued_at \u003e 0),\n expires_at INTEGER,\n UNIQUE(consumer_id,slot),\n CHECK((slot = 'current' AND expires_at IS NULL) OR\n (slot = 'previous' AND typeof(expires_at) = 'integer' AND expires_at \u003e issued_at AND expires_at - issued_at \u003c= 300))\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "sessions", |
| + "Table": "sessions", |
| + "SQL": "CREATE TABLE sessions (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n auth_time TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "sqlite_sequence", |
| + "Table": "sqlite_sequence", |
| + "SQL": "CREATE TABLE sqlite_sequence(name,seq)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "team_instances", |
| + "Table": "team_instances", |
| + "SQL": "CREATE TABLE team_instances (\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n app TEXT NOT NULL,\n origin TEXT NOT NULL UNIQUE,\n consumer_id TEXT NOT NULL UNIQUE,\n state TEXT NOT NULL CHECK(state IN ('pending', 'live', 'disabled')),\n PRIMARY KEY(team_id, app)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "teams", |
| + "Table": "teams", |
| + "SQL": "CREATE TABLE teams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n sqid TEXT NOT NULL UNIQUE CHECK(length(sqid) BETWEEN 1 AND 64 AND sqid NOT GLOB '*[^a-zA-Z0-9]*'),\n label TEXT NOT NULL UNIQUE CHECK(length(label) BETWEEN 1 AND 63 AND label NOT GLOB '*[^a-z0-9-]*' AND substr(label,1,1) != '-' AND substr(label,-1,1) != '-'),\n sealed_name BLOB NOT NULL CHECK(typeof(sealed_name) = 'blob' AND length(sealed_name) BETWEEN 29 AND 284),\n created_at INTEGER NOT NULL CHECK(created_at \u003e 0),\n roster_epoch INTEGER NOT NULL DEFAULT 1 CHECK(typeof(roster_epoch) = 'integer' AND roster_epoch \u003e 0)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_active", |
| + "Table": "verification_active", |
| + "SQL": "CREATE TABLE verification_active (\n consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),\n operation_id TEXT NOT NULL UNIQUE REFERENCES verification_attempts(operation_id)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_attempts", |
| + "Table": "verification_attempts", |
| + "SQL": "CREATE TABLE verification_attempts (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id),\n team_id INTEGER NOT NULL REFERENCES teams(id),\n app TEXT NOT NULL,\n origin TEXT NOT NULL,\n generation TEXT NOT NULL CHECK(length(generation)=43),\n record_sequence INTEGER NOT NULL CHECK(typeof(record_sequence)='integer' AND record_sequence\u003e0),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32),\n expected_epoch INTEGER NOT NULL CHECK(typeof(expected_epoch)='integer' AND expected_epoch\u003e0),\n owner_ref TEXT NOT NULL CHECK(length(owner_ref)=29),\n issued_at INTEGER NOT NULL CHECK(typeof(issued_at)='integer' AND issued_at\u003e0),\n expires_at INTEGER NOT NULL CHECK(typeof(expires_at)='integer' AND expires_at\u003eissued_at AND expires_at-issued_at\u003c=600),\n authorization_digest BLOB NOT NULL CHECK(typeof(authorization_digest)='blob' AND length(authorization_digest)=32)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_outcomes", |
| + "Table": "verification_outcomes", |
| + "SQL": "CREATE TABLE verification_outcomes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n attempt_id TEXT NOT NULL REFERENCES verification_attempts(operation_id),\n operator_operation_id TEXT NOT NULL CHECK(length(operator_operation_id)=43),\n kind TEXT NOT NULL CHECK(kind IN ('opened','expired','activated')),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0),\n UNIQUE(attempt_id,operator_operation_id,kind)\n)" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_delete", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_delete AFTER DELETE ON memberships BEGIN\n SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = OLD.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = OLD.team_id;\nEND" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_insert", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_insert AFTER INSERT ON memberships BEGIN\n SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = NEW.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = NEW.team_id;\nEND" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_update", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_update AFTER UPDATE ON memberships BEGIN\n SELECT CASE WHEN EXISTS(SELECT 1 FROM teams WHERE id IN (OLD.team_id,NEW.team_id) AND roster_epoch = 9223372036854775807) THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id IN (OLD.team_id,NEW.team_id);\nEND" |
| + } |
| + ], |
| + "empty_tables": [] |
| +} |
diff --git a/internal/home/admission_profiles/directory-historical-empty-v1.json b/internal/home/admission_profiles/directory-historical-empty-v1.json| new file mode 100644 |
| index 0000000..a473ef6 |
| --- /dev/null |
| +++ b/internal/home/admission_profiles/directory-historical-empty-v1.json |
| @@ -0,0 +1,506 @@ |
| +{ |
| + "version": 1, |
| + "id": "directory-historical-empty-v1", |
| + "role": "directory", |
| + "validation_policy": 1, |
| + "migrations": [ |
| + { |
| + "id": "home/0001_people", |
| + "checksum": "5e65372f0796be99d6fcbba09c97b92ed36a5faf1bf36a53be6f5caf0771eb9b" |
| + }, |
| + { |
| + "id": "home/0002_signin_links", |
| + "checksum": "11429f3a12f605f0e24f890952a2831a44d8e5030d10471c7a5c1c3672e630a7" |
| + }, |
| + { |
| + "id": "oficina_directory/0001_directory", |
| + "checksum": "d21fb3a2c8021c25110293d1fb7d3cad7391f020e33d3baba5b4bb2a4286d122" |
| + }, |
| + { |
| + "id": "oficina_directory_operator/0001_operator", |
| + "checksum": "c2b659069da4459aa79cdd89160656d3b850474b59bfb01670ad477165055956" |
| + }, |
| + { |
| + "id": "oficina_directory_operator/0002_owner_receipts", |
| + "checksum": "b916fd5db6c9a69cff4904f6d66fad8aedba12163520c6e42351561d8c1b2a5a" |
| + }, |
| + { |
| + "id": "oficina_directory_operator/0003_operator_key_identity", |
| + "checksum": "56f2a3966f9752f46773c2eba0cc8fb74114d87b169728c4ef68b63a665eb8b9" |
| + }, |
| + { |
| + "id": "oficina_directory_operator_bindings/0001_operator_bindings", |
| + "checksum": "0cfda439adfbb909a9b2bc88e27fb4d148ae1e60997faaf3ef130d42610f8136" |
| + }, |
| + { |
| + "id": "oficina_directory_roster/0001_roster", |
| + "checksum": "cae647391de9529a39488706e123cab70392ab104b4698b5092d08ec6d7ced6c" |
| + }, |
| + { |
| + "id": "oficina_handoff/0001_exchange", |
| + "checksum": "d2b6b9c416a34c3711e4959b0a05fdd13b80bc2f68b20146210c14d67fa1564e" |
| + }, |
| + { |
| + "id": "passkey/0001_init", |
| + "checksum": "56d5073a880c3b1b8330654873b13cbd42a72596138499380b5a013e4d43196e" |
| + }, |
| + { |
| + "id": "sessions/0001_init", |
| + "checksum": "50bbfc92cfcc708b09b672b71f24da789ae6065f426c9db7af61befa90098bbb" |
| + } |
| + ], |
| + "objects": [ |
| + { |
| + "Type": "index", |
| + "Name": "idx_signin_links_expires_at", |
| + "Table": "signin_links", |
| + "SQL": "CREATE INDEX idx_signin_links_expires_at ON signin_links(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "memberships_person", |
| + "Table": "memberships", |
| + "SQL": "CREATE INDEX memberships_person ON memberships(person_ref, team_id)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_inbox_expiry", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "CREATE INDEX oficina_handoff_inbox_expiry ON oficina_handoff_inbox(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_pending_expiry", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "CREATE INDEX oficina_handoff_pending_expiry ON oficina_handoff_pending(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "oficina_handoff_replay_expiry", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "CREATE INDEX oficina_handoff_replay_expiry ON oficina_handoff_replay(expires_at)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "passkey_credentials_subject", |
| + "Table": "passkey_credentials", |
| + "SQL": "CREATE INDEX passkey_credentials_subject\n ON passkey_credentials (subject)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "passkey_recovery_codes_subject", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "CREATE INDEX passkey_recovery_codes_subject\n ON passkey_recovery_codes (subject)" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_consumer_records_1", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_consumer_records_2", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_1", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_2", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_owner_receipts_3", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_results_1", |
| + "Table": "directory_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_directory_operator_results_2", |
| + "Table": "directory_operator_results", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_memberships_1", |
| + "Table": "memberships", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_inbox_1", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_inbox_2", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_pending_1", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_pending_2", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_oficina_handoff_replay_1", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_challenges_1", |
| + "Table": "passkey_challenges", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_credentials_1", |
| + "Table": "passkey_credentials", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_pending_1", |
| + "Table": "passkey_pending", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_passkey_recovery_codes_1", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_people_1", |
| + "Table": "people", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_people_2", |
| + "Table": "people", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_rastrillo_migrations_1", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_consumers_1", |
| + "Table": "roster_consumers", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_operator_receipts_1", |
| + "Table": "roster_operator_receipts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_tokens_1", |
| + "Table": "roster_tokens", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_roster_tokens_2", |
| + "Table": "roster_tokens", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_sessions_1", |
| + "Table": "sessions", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_signin_links_1", |
| + "Table": "signin_links", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_1", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_2", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_team_instances_3", |
| + "Table": "team_instances", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_teams_1", |
| + "Table": "teams", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_teams_2", |
| + "Table": "teams", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_active_1", |
| + "Table": "verification_active", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_active_2", |
| + "Table": "verification_active", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_attempts_1", |
| + "Table": "verification_attempts", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "index", |
| + "Name": "sqlite_autoindex_verification_outcomes_1", |
| + "Table": "verification_outcomes", |
| + "SQL": "" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_lock", |
| + "Table": "_litestream_lock", |
| + "SQL": "CREATE TABLE _litestream_lock (id INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "_litestream_seq", |
| + "Table": "_litestream_seq", |
| + "SQL": "CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_consumer_records", |
| + "Table": "directory_operator_consumer_records", |
| + "SQL": "CREATE TABLE directory_operator_consumer_records (\n consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),\n operation_id TEXT NOT NULL UNIQUE REFERENCES roster_operator_receipts(operation_id),\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n generation TEXT NOT NULL CHECK(length(generation)=43),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_owner_receipts", |
| + "Table": "directory_operator_owner_receipts", |
| + "SQL": "CREATE TABLE directory_operator_owner_receipts (\n issuer TEXT NOT NULL,\n owner_operation_id TEXT NOT NULL CHECK(length(owner_operation_id)=43),\n directory_operation_id TEXT NOT NULL UNIQUE REFERENCES directory_operator_results(operation_id) DEFERRABLE INITIALLY DEFERRED,\n team_id INTEGER NOT NULL UNIQUE REFERENCES teams(id),\n PRIMARY KEY(issuer,owner_operation_id)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_results", |
| + "Table": "directory_operator_results", |
| + "SQL": "CREATE TABLE directory_operator_results (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n action TEXT NOT NULL,\n sequence INTEGER NOT NULL UNIQUE CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n authorization_head TEXT NOT NULL CHECK(length(authorization_head)=43),\n request_mac BLOB NOT NULL CHECK(typeof(request_mac)='blob' AND length(request_mac)=32),\n sealed_result BLOB NOT NULL CHECK(typeof(sealed_result)='blob' AND length(sealed_result) BETWEEN 29 AND 49180),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, operator_ref TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "directory_operator_state", |
| + "Table": "directory_operator_state", |
| + "SQL": "CREATE TABLE directory_operator_state (\n singleton INTEGER PRIMARY KEY CHECK(singleton=1),\n origin TEXT NOT NULL,\n installation TEXT NOT NULL,\n registry TEXT NOT NULL,\n device TEXT NOT NULL,\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n head TEXT NOT NULL CHECK(length(head)=43),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, key_id TEXT NOT NULL DEFAULT '')" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "memberships", |
| + "Table": "memberships", |
| + "SQL": "CREATE TABLE memberships (\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n person_ref TEXT NOT NULL CHECK(length(person_ref) = 29 AND substr(person_ref,1,3) = 'pr_' AND substr(person_ref,4) NOT GLOB '*[^a-z2-7]*'),\n role TEXT NOT NULL CHECK(role IN ('owner', 'member')),\n PRIMARY KEY(team_id, person_ref)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_clock", |
| + "Table": "oficina_handoff_clock", |
| + "SQL": "CREATE TABLE oficina_handoff_clock (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n observed INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_inbox", |
| + "Table": "oficina_handoff_inbox", |
| + "SQL": "CREATE TABLE oficina_handoff_inbox (\n handle_hash TEXT PRIMARY KEY,\n jti_hash TEXT NOT NULL UNIQUE,\n sealed_token BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_pending", |
| + "Table": "oficina_handoff_pending", |
| + "SQL": "CREATE TABLE oficina_handoff_pending (\n secret_hash TEXT PRIMARY KEY,\n nonce_hash TEXT NOT NULL UNIQUE,\n sealed_path BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "oficina_handoff_replay", |
| + "Table": "oficina_handoff_replay", |
| + "SQL": "CREATE TABLE oficina_handoff_replay (\n jti_hash TEXT PRIMARY KEY,\n expires_at INTEGER NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_challenges", |
| + "Table": "passkey_challenges", |
| + "SQL": "CREATE TABLE passkey_challenges (\n challenge TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n purpose TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_credentials", |
| + "Table": "passkey_credentials", |
| + "SQL": "CREATE TABLE passkey_credentials (\n id TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n public_key BLOB NOT NULL,\n sign_count INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_pending", |
| + "Table": "passkey_pending", |
| + "SQL": "CREATE TABLE passkey_pending (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n return_to TEXT NOT NULL DEFAULT '',\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "passkey_recovery_codes", |
| + "Table": "passkey_recovery_codes", |
| + "SQL": "CREATE TABLE passkey_recovery_codes (\n code_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "people", |
| + "Table": "people", |
| + "SQL": "CREATE TABLE people (\n person_ref TEXT PRIMARY KEY,\n blind_index TEXT NOT NULL UNIQUE, -- UNIQUE already indexes it; no second index\n created_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "rastrillo_migrations", |
| + "Table": "rastrillo_migrations", |
| + "SQL": "CREATE TABLE rastrillo_migrations (\n id TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL,\n checksum TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_consumers", |
| + "Table": "roster_consumers", |
| + "SQL": "CREATE TABLE roster_consumers (\n consumer_id TEXT PRIMARY KEY REFERENCES team_instances(consumer_id) ON DELETE RESTRICT,\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n app TEXT NOT NULL,\n origin TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK(enabled IN (0,1)),\n FOREIGN KEY(team_id,app) REFERENCES team_instances(team_id,app) ON DELETE RESTRICT\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_generation", |
| + "Table": "roster_generation", |
| + "SQL": "CREATE TABLE roster_generation (\n singleton INTEGER PRIMARY KEY CHECK(singleton = 1),\n generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*')\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_operator_receipts", |
| + "Table": "roster_operator_receipts", |
| + "SQL": "CREATE TABLE roster_operator_receipts (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id) BETWEEN 1 AND 128 AND operation_id NOT GLOB '*[^a-zA-Z0-9_-]*'),\n sequence INTEGER NOT NULL CHECK(typeof(sequence) = 'integer' AND sequence \u003e 0),\n operation TEXT NOT NULL CHECK(operation IN ('bootstrap','provision','rotate','disable','restore')),\n consumer_id TEXT CHECK(consumer_id IS NULL OR (length(consumer_id) BETWEEN 1 AND 128 AND consumer_id NOT GLOB '*[^a-zA-Z0-9_-]*')),\n generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*'),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest) = 'blob' AND length(record_digest) = 32),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at) = 'integer' AND completed_at \u003e 0)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "roster_tokens", |
| + "Table": "roster_tokens", |
| + "SQL": "CREATE TABLE roster_tokens (\n token_hash BLOB PRIMARY KEY CHECK(typeof(token_hash) = 'blob' AND length(token_hash) = 32),\n consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id) ON DELETE RESTRICT,\n slot TEXT NOT NULL CHECK(slot IN ('current','previous')),\n issued_at INTEGER NOT NULL CHECK(typeof(issued_at) = 'integer' AND issued_at \u003e 0),\n expires_at INTEGER,\n UNIQUE(consumer_id,slot),\n CHECK((slot = 'current' AND expires_at IS NULL) OR\n (slot = 'previous' AND typeof(expires_at) = 'integer' AND expires_at \u003e issued_at AND expires_at - issued_at \u003c= 300))\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "sessions", |
| + "Table": "sessions", |
| + "SQL": "CREATE TABLE sessions (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n auth_time TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "signin_links", |
| + "Table": "signin_links", |
| + "SQL": "CREATE TABLE signin_links (\n token_hash TEXT PRIMARY KEY,\n sealed_address BLOB NOT NULL,\n purpose TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "sqlite_sequence", |
| + "Table": "sqlite_sequence", |
| + "SQL": "CREATE TABLE sqlite_sequence(name,seq)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "team_instances", |
| + "Table": "team_instances", |
| + "SQL": "CREATE TABLE team_instances (\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n app TEXT NOT NULL,\n origin TEXT NOT NULL UNIQUE,\n consumer_id TEXT NOT NULL UNIQUE,\n state TEXT NOT NULL CHECK(state IN ('pending', 'live', 'disabled')),\n PRIMARY KEY(team_id, app)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "teams", |
| + "Table": "teams", |
| + "SQL": "CREATE TABLE teams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n sqid TEXT NOT NULL UNIQUE CHECK(length(sqid) BETWEEN 1 AND 64 AND sqid NOT GLOB '*[^a-zA-Z0-9]*'),\n label TEXT NOT NULL UNIQUE CHECK(length(label) BETWEEN 1 AND 63 AND label NOT GLOB '*[^a-z0-9-]*' AND substr(label,1,1) != '-' AND substr(label,-1,1) != '-'),\n sealed_name BLOB NOT NULL CHECK(typeof(sealed_name) = 'blob' AND length(sealed_name) BETWEEN 29 AND 284),\n created_at INTEGER NOT NULL CHECK(created_at \u003e 0),\n roster_epoch INTEGER NOT NULL DEFAULT 1 CHECK(typeof(roster_epoch) = 'integer' AND roster_epoch \u003e 0)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_active", |
| + "Table": "verification_active", |
| + "SQL": "CREATE TABLE verification_active (\n consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),\n operation_id TEXT NOT NULL UNIQUE REFERENCES verification_attempts(operation_id)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_attempts", |
| + "Table": "verification_attempts", |
| + "SQL": "CREATE TABLE verification_attempts (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id),\n team_id INTEGER NOT NULL REFERENCES teams(id),\n app TEXT NOT NULL,\n origin TEXT NOT NULL,\n generation TEXT NOT NULL CHECK(length(generation)=43),\n record_sequence INTEGER NOT NULL CHECK(typeof(record_sequence)='integer' AND record_sequence\u003e0),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32),\n expected_epoch INTEGER NOT NULL CHECK(typeof(expected_epoch)='integer' AND expected_epoch\u003e0),\n owner_ref TEXT NOT NULL CHECK(length(owner_ref)=29),\n issued_at INTEGER NOT NULL CHECK(typeof(issued_at)='integer' AND issued_at\u003e0),\n expires_at INTEGER NOT NULL CHECK(typeof(expires_at)='integer' AND expires_at\u003eissued_at AND expires_at-issued_at\u003c=600),\n authorization_digest BLOB NOT NULL CHECK(typeof(authorization_digest)='blob' AND length(authorization_digest)=32)\n)" |
| + }, |
| + { |
| + "Type": "table", |
| + "Name": "verification_outcomes", |
| + "Table": "verification_outcomes", |
| + "SQL": "CREATE TABLE verification_outcomes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n attempt_id TEXT NOT NULL REFERENCES verification_attempts(operation_id),\n operator_operation_id TEXT NOT NULL CHECK(length(operator_operation_id)=43),\n kind TEXT NOT NULL CHECK(kind IN ('opened','expired','activated')),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0),\n UNIQUE(attempt_id,operator_operation_id,kind)\n)" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_delete", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_delete AFTER DELETE ON memberships BEGIN\n SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = OLD.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = OLD.team_id;\nEND" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_insert", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_insert AFTER INSERT ON memberships BEGIN\n SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = NEW.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = NEW.team_id;\nEND" |
| + }, |
| + { |
| + "Type": "trigger", |
| + "Name": "memberships_epoch_update", |
| + "Table": "memberships", |
| + "SQL": "CREATE TRIGGER memberships_epoch_update AFTER UPDATE ON memberships BEGIN\n SELECT CASE WHEN EXISTS(SELECT 1 FROM teams WHERE id IN (OLD.team_id,NEW.team_id) AND roster_epoch = 9223372036854775807) THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id IN (OLD.team_id,NEW.team_id);\nEND" |
| + } |
| + ], |
| + "empty_tables": [ |
| + "passkey_challenges", |
| + "passkey_credentials", |
| + "passkey_pending", |
| + "passkey_recovery_codes", |
| + "people", |
| + "signin_links" |
| + ] |
| +} |
diff --git a/internal/home/schema_commitment_linux.go b/internal/home/schema_commitment_linux.go| new file mode 100644 |
| index 0000000..b5f46bb |
| --- /dev/null |
| +++ b/internal/home/schema_commitment_linux.go |
| @@ -0,0 +1,248 @@ |
| +//go:build linux |
| + |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "embed" |
| + "encoding/hex" |
| + "encoding/json" |
| + "strings" |
| + |
| + "amadan.net/oficina/home/internal/operator" |
| +) |
| + |
| +// Admission is deliberately unmounted until verified platform enrollment exists. |
| +// These manifests commit exact bytes; diagnostic observations cannot select one. |
| +// |
| +//go:embed admission_profiles/*.json |
| +var admissionProfiles embed.FS |
| + |
| +type schemaCommitmentMigration struct { |
| + ID string `json:"id"` |
| + Checksum string `json:"checksum"` |
| +} |
| +type schemaCommitmentManifest struct { |
| + Version int `json:"version"` |
| + ID string `json:"id"` |
| + Role string `json:"role"` |
| + ValidationPolicy int `json:"validation_policy"` |
| + Migrations []schemaCommitmentMigration `json:"migrations"` |
| + Objects []schemaObject `json:"objects"` |
| + EmptyTables []string `json:"empty_tables"` |
| +} |
| + |
| +type schemaCommitment struct { |
| + manifest schemaCommitmentManifest |
| + digest string |
| +} |
| + |
| +func decodeSchemaCommitment(raw []byte) (schemaCommitmentManifest, error) { |
| + var m schemaCommitmentManifest |
| + if len(raw) == 0 || len(raw) > 1<<20 { |
| + return m, operator.ErrRefused |
| + } |
| + d := json.NewDecoder(bytes.NewReader(raw)) |
| + d.DisallowUnknownFields() |
| + if d.Decode(&m) != nil { |
| + return m, operator.ErrRefused |
| + } |
| + canonical, err := json.MarshalIndent(m, "", " ") |
| + if err != nil || !bytes.Equal(append(canonical, '\n'), raw) || m.Version != 1 || m.ValidationPolicy != 1 || |
| + len(m.Migrations) == 0 || len(m.Migrations) > 1000 || len(m.Objects) == 0 || len(m.Objects) > 1000 || m.EmptyTables == nil { |
| + return m, operator.ErrRefused |
| + } |
| + switch m.ID { |
| + case "credential-current-v1": |
| + if m.Role != "credential" || len(m.EmptyTables) != 0 { |
| + return m, operator.ErrRefused |
| + } |
| + case "directory-current-v1": |
| + if m.Role != "directory" || len(m.EmptyTables) != 0 { |
| + return m, operator.ErrRefused |
| + } |
| + case "directory-historical-empty-v1": |
| + if m.Role != "directory" || strings.Join(m.EmptyTables, ",") != "passkey_challenges,passkey_credentials,passkey_pending,passkey_recovery_codes,people,signin_links" { |
| + return m, operator.ErrRefused |
| + } |
| + default: |
| + return m, operator.ErrRefused |
| + } |
| + for i, migration := range m.Migrations { |
| + sum, err := hex.DecodeString(migration.Checksum) |
| + if migration.ID == "" || len(migration.ID) > 256 || err != nil || len(sum) != 32 || hex.EncodeToString(sum) != migration.Checksum || |
| + (i > 0 && m.Migrations[i-1].ID >= migration.ID) { |
| + return m, operator.ErrRefused |
| + } |
| + } |
| + tables := map[string]bool{} |
| + for i, obj := range m.Objects { |
| + if obj.Type != "table" && obj.Type != "index" && obj.Type != "trigger" && obj.Type != "view" { |
| + return m, operator.ErrRefused |
| + } |
| + if obj.Name == "" || obj.Table == "" || len(obj.Name) > 256 || len(obj.Table) > 256 || len(obj.SQL) > 65536 { |
| + return m, operator.ErrRefused |
| + } |
| + if i > 0 { |
| + prior := m.Objects[i-1] |
| + if prior.Type > obj.Type || (prior.Type == obj.Type && prior.Name >= obj.Name) { |
| + return m, operator.ErrRefused |
| + } |
| + } |
| + if obj.Type == "table" { |
| + tables[obj.Name] = true |
| + } |
| + } |
| + for _, table := range m.EmptyTables { |
| + if !tables[table] { |
| + return m, operator.ErrRefused |
| + } |
| + } |
| + return m, nil |
| +} |
| + |
| +func loadSchemaCommitment(role, id, digest string) (schemaCommitment, error) { |
| + var result schemaCommitment |
| + switch id { |
| + case "credential-current-v1", "directory-current-v1", "directory-historical-empty-v1": |
| + default: |
| + return result, operator.ErrRefused |
| + } |
| + raw, err := admissionProfiles.ReadFile("admission_profiles/" + id + ".json") |
| + if err != nil { |
| + return result, operator.ErrUnavailable |
| + } |
| + sum := sha256.Sum256(raw) |
| + if hex.EncodeToString(sum[:]) != digest { |
| + return result, operator.ErrRefused |
| + } |
| + manifest, err := decodeSchemaCommitment(raw) |
| + if err != nil || manifest.Role != role || manifest.ID != id { |
| + return result, operator.ErrRefused |
| + } |
| + return schemaCommitment{manifest: manifest, digest: digest}, nil |
| +} |
| + |
| +// Caller must hold the same transaction through schema checking and any later |
| +// operation. Stage A supplies no transaction, database opener or production caller. |
| +func checkSchemaCommitment(ctx context.Context, conn *sql.Conn, selected schemaCommitment) error { |
| + if ctx == nil || conn == nil { |
| + return operator.ErrRefused |
| + } |
| + pinned, err := loadSchemaCommitment(selected.manifest.Role, selected.manifest.ID, selected.digest) |
| + if err != nil { |
| + return err |
| + } |
| + m := pinned.manifest |
| + expected := map[string]string{} |
| + for _, migration := range m.Migrations { |
| + expected[migration.ID] = migration.Checksum |
| + } |
| + rows, err := conn.QueryContext(ctx, `SELECT |
| + CASE WHEN typeof(id)='text' AND length(CAST(id AS BLOB))<=256 THEN id END, |
| + CASE WHEN typeof(checksum)='text' AND length(CAST(checksum AS BLOB))=64 THEN checksum END |
| + FROM rastrillo_migrations LIMIT 1001`) |
| + if err != nil { |
| + return operator.ErrRefused |
| + } |
| + matched, count := true, 0 |
| + for rows.Next() { |
| + count++ |
| + var id, checksum sql.NullString |
| + if rows.Scan(&id, &checksum) != nil { |
| + rows.Close() |
| + return operator.ErrRefused |
| + } |
| + if !id.Valid || !checksum.Valid || count > 1000 || expected[id.String] != checksum.String { |
| + matched = false |
| + } |
| + delete(expected, id.String) |
| + } |
| + scanErr, closeErr := rows.Err(), rows.Close() |
| + if scanErr != nil || closeErr != nil || ctx.Err() != nil || !matched || len(expected) != 0 { |
| + return operator.ErrRefused |
| + } |
| + objects := map[string]schemaObject{} |
| + for _, obj := range m.Objects { |
| + objects[obj.Type+"/"+obj.Name] = obj |
| + } |
| + rows, err = conn.QueryContext(ctx, `SELECT |
| + CASE WHEN typeof(type)='text' AND length(CAST(type AS BLOB))<=16 THEN type END, |
| + CASE WHEN typeof(name)='text' AND length(CAST(name AS BLOB))<=256 THEN name END, |
| + CASE WHEN typeof(tbl_name)='text' AND length(CAST(tbl_name AS BLOB))<=256 THEN tbl_name END, |
| + CASE WHEN typeof(coalesce(sql,''))='text' AND length(CAST(coalesce(sql,'') AS BLOB))<=65536 THEN coalesce(sql,'') END |
| + FROM sqlite_schema LIMIT 1001`) |
| + if err != nil { |
| + return operator.ErrRefused |
| + } |
| + count = 0 |
| + for rows.Next() { |
| + count++ |
| + var kind, name, table, statement sql.NullString |
| + if rows.Scan(&kind, &name, &table, &statement) != nil { |
| + rows.Close() |
| + return operator.ErrRefused |
| + } |
| + obj := schemaObject{kind.String, name.String, table.String, statement.String} |
| + key := kind.String + "/" + name.String |
| + prior, exists := objects[key] |
| + if !kind.Valid || !name.Valid || !table.Valid || !statement.Valid || count > 1000 || !exists || prior != obj { |
| + matched = false |
| + } |
| + delete(objects, key) |
| + } |
| + scanErr, closeErr = rows.Err(), rows.Close() |
| + if scanErr != nil || closeErr != nil || ctx.Err() != nil || !matched || len(objects) != 0 { |
| + return operator.ErrRefused |
| + } |
| + total := 0 |
| + for _, obj := range m.Objects { |
| + if obj.Type != "table" { |
| + continue |
| + } |
| + var count int |
| + query := `SELECT count(*) FROM (SELECT 1 FROM "` + strings.ReplaceAll(obj.Name, `"`, `""`) + `" LIMIT 100001)` |
| + if conn.QueryRowContext(ctx, query).Scan(&count) != nil { |
| + return operator.ErrRefused |
| + } |
| + total += count |
| + if total > 100000 { |
| + return operator.ErrUnavailable |
| + } |
| + } |
| + for _, table := range m.EmptyTables { |
| + var occupied bool |
| + if conn.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "`+table+`")`).Scan(&occupied) != nil || occupied { |
| + return operator.ErrRefused |
| + } |
| + } |
| + rows, err = conn.QueryContext(ctx, "PRAGMA foreign_key_check") |
| + if err != nil { |
| + return operator.ErrRefused |
| + } |
| + bad := rows.Next() |
| + scanErr, closeErr = rows.Err(), rows.Close() |
| + if bad || scanErr != nil || closeErr != nil { |
| + return operator.ErrRefused |
| + } |
| + var n int |
| + if conn.QueryRowContext(ctx, "SELECT count(*) FROM (SELECT 1 FROM _litestream_lock LIMIT 1)").Scan(&n) != nil || n != 0 { |
| + return operator.ErrRefused |
| + } |
| + if conn.QueryRowContext(ctx, "SELECT count(*) FROM (SELECT 1 FROM _litestream_seq LIMIT 2)").Scan(&n) != nil || n > 1 { |
| + return operator.ErrRefused |
| + } |
| + if n == 1 { |
| + var valid int |
| + if conn.QueryRowContext(ctx, "SELECT typeof(id)='integer' AND id=1 AND typeof(seq)='integer' AND seq>=1 FROM _litestream_seq").Scan(&valid) != nil || valid != 1 { |
| + return operator.ErrRefused |
| + } |
| + } |
| + if ctx.Err() != nil { |
| + return operator.ErrRefused |
| + } |
| + return nil |
| +} |
diff --git a/internal/home/schema_commitment_linux_test.go b/internal/home/schema_commitment_linux_test.go| new file mode 100644 |
| index 0000000..a726249 |
| --- /dev/null |
| +++ b/internal/home/schema_commitment_linux_test.go |
| @@ -0,0 +1,294 @@ |
| +//go:build linux |
| + |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/hex" |
| + "encoding/json" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "io/fs" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +func TestSchemaCommitmentFrozenCatalog(t *testing.T) { |
| + for _, id := range []string{"credential-current-v1", "directory-current-v1", "directory-historical-empty-v1"} { |
| + t.Run(id, func(t *testing.T) { |
| + role := strings.Split(id, "-")[0] |
| + migrations := compatibilitySchema(role).All() |
| + objectsRaw, err := compatibilityProfiles.ReadFile("compatibility_profiles/" + role + ".json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + empty := []string{} |
| + if id == "directory-historical-empty-v1" { |
| + objectsRaw = legacyDiagnosticJSON |
| + var ok bool |
| + migrations, ok = legacyDiagnosticMigrations() |
| + if !ok { |
| + t.Fatal("historical migration conflict") |
| + } |
| + empty = []string{"passkey_challenges", "passkey_credentials", "passkey_pending", "passkey_recovery_codes", "people", "signin_links"} |
| + } |
| + m := schemaCommitmentManifest{Version: 1, ID: id, Role: role, ValidationPolicy: 1, EmptyTables: empty, Migrations: []schemaCommitmentMigration{}} |
| + if json.Unmarshal(objectsRaw, &m.Objects) != nil { |
| + t.Fatal("frozen objects") |
| + } |
| + for _, migration := range migrations { |
| + if migration.Fn != nil { |
| + t.Fatal("Go migration cannot be committed") |
| + } |
| + m.Migrations = append(m.Migrations, schemaCommitmentMigration{migration.ID, migrate.Checksum(migration.SQL)}) |
| + } |
| + sort.Slice(m.Migrations, func(i, j int) bool { return m.Migrations[i].ID < m.Migrations[j].ID }) |
| + sort.Slice(m.Objects, func(i, j int) bool { |
| + a, b := m.Objects[i], m.Objects[j] |
| + return a.Type < b.Type || a.Type == b.Type && a.Name < b.Name |
| + }) |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw = append(raw, '\n') |
| + name := "admission_profiles/" + id + ".json" |
| + if os.Getenv("OFICINA_GENERATE_ADMISSION_PROFILE") == "1" { |
| + if err := os.WriteFile(name, raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + return |
| + } |
| + frozen, err := admissionProfiles.ReadFile(name) |
| + if err != nil || !bytes.Equal(raw, frozen) { |
| + t.Fatal("admission manifest differs from frozen schema/provenance") |
| + } |
| + if _, err := decodeSchemaCommitment(frozen); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func selectedSchema(t *testing.T, id string) schemaCommitment { |
| + t.Helper() |
| + raw, err := admissionProfiles.ReadFile("admission_profiles/" + id + ".json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(raw) |
| + selected, err := loadSchemaCommitment(strings.Split(id, "-")[0], id, hex.EncodeToString(sum[:])) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return selected |
| +} |
| + |
| +func TestSchemaCommitmentRefusesWireAndSelectionChanges(t *testing.T) { |
| + selected := selectedSchema(t, "directory-historical-empty-v1") |
| + raw, _ := admissionProfiles.ReadFile("admission_profiles/directory-historical-empty-v1.json") |
| + for name, changed := range map[string][]byte{ |
| + "newline": bytes.TrimSuffix(raw, []byte{'\n'}), |
| + "whitespace": append([]byte{' '}, raw...), |
| + "duplicate": bytes.Replace(raw, []byte(`"version": 1,`), []byte("\"version\": 1,\n \"version\": 1,"), 1), |
| + "unknown": bytes.Replace(raw, []byte(`"version": 1,`), []byte(`"unknown": 1, "version": 1,`), 1), |
| + "wrong-policy": bytes.Replace(raw, []byte(`"validation_policy": 1`), []byte(`"validation_policy": 2`), 1), |
| + "trailing-json": append(append([]byte{}, raw...), []byte("{}\n")...), |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := decodeSchemaCommitment(changed); err == nil { |
| + t.Fatal("accepted altered wire") |
| + } |
| + }) |
| + } |
| + for _, input := range [][3]string{ |
| + {"credential", selected.manifest.ID, selected.digest}, |
| + {"directory", "../directory-historical-empty-v1", selected.digest}, |
| + {"directory", selected.manifest.ID, strings.Repeat("0", 64)}, |
| + {"directory", "", selected.digest}, {"directory", selected.manifest.ID, ""}, |
| + } { |
| + if _, err := loadSchemaCommitment(input[0], input[1], input[2]); err == nil { |
| + t.Fatal("accepted altered selection") |
| + } |
| + } |
| +} |
| + |
| +func checkSelectedSchema(t *testing.T, d *sql.DB, selected schemaCommitment) error { |
| + t.Helper() |
| + conn, err := d.Conn(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer conn.Close() |
| + if _, err := conn.ExecContext(t.Context(), "BEGIN IMMEDIATE"); err != nil { |
| + t.Fatal(err) |
| + } |
| + result := checkSchemaCommitment(t.Context(), conn, selected) |
| + if _, err := conn.ExecContext(t.Context(), "ROLLBACK"); err != nil { |
| + t.Fatal(err) |
| + } |
| + return result |
| +} |
| + |
| +func TestSchemaCommitmentReplayAndOccupancy(t *testing.T) { |
| + for _, role := range []string{"credential", "directory"} { |
| + d := compatibilityFixture(t, role) |
| + if err := checkSelectedSchema(t, d, selectedSchema(t, role+"-current-v1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + d.Close() |
| + } |
| + selected := selectedSchema(t, "directory-historical-empty-v1") |
| + for name, change := range map[string]string{ |
| + "valid": "", |
| + "people": "INSERT INTO people VALUES('person','index','time')", |
| + "signin_links": "INSERT INTO signin_links VALUES('token',x'00','purpose','time')", |
| + "passkey_credentials": "INSERT INTO passkey_credentials VALUES('id','person',x'00',0,'time')", |
| + "passkey_challenges": "INSERT INTO passkey_challenges VALUES('challenge','person','purpose','time')", |
| + "passkey_pending": "INSERT INTO passkey_pending VALUES('token','person','method','/','time')", |
| + "passkey_recovery_codes": "INSERT INTO passkey_recovery_codes VALUES('code','person','time')", |
| + "checksum": "UPDATE rastrillo_migrations SET checksum='wrong'", |
| + "missing-migration": "DELETE FROM rastrillo_migrations WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)", |
| + "extra-table": "CREATE TABLE unexpected(value TEXT)", |
| + "extra-trigger": "CREATE TRIGGER unexpected AFTER DELETE ON people BEGIN SELECT 1; END", |
| + "replication-lock": "INSERT INTO _litestream_lock VALUES(1)", |
| + "replication-seq": "INSERT INTO _litestream_seq VALUES(2,1)", |
| + "oversized-id": "UPDATE rastrillo_migrations SET id=printf('%300s',id) WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)", |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + d := legacyDiagnosticFixture(t, true, true) |
| + if err := checkSelectedSchema(t, d.Writer(), selected); err != nil { |
| + t.Fatal("baseline", err) |
| + } |
| + if change != "" { |
| + if _, err := d.Writer().Exec(change); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + err := checkSelectedSchema(t, d.Writer(), selected) |
| + if (name == "valid") != (err == nil) { |
| + t.Fatalf("check %s: %v", name, err) |
| + } |
| + }) |
| + } |
| + d := legacyDiagnosticFixture(t, true, true) |
| + if err := checkSelectedSchema(t, d.Writer(), selectedSchema(t, "directory-current-v1")); err == nil { |
| + t.Fatal("fresh profile accepted historical database") |
| + } |
| + fresh := compatibilityFixture(t, "directory") |
| + defer fresh.Close() |
| + if err := checkSelectedSchema(t, fresh, selected); err == nil { |
| + t.Fatal("historical profile accepted fresh database") |
| + } |
| +} |
| + |
| +func TestSchemaCommitmentHasNoProductionCaller(t *testing.T) { |
| + // Every reference outside the admission implementation is forbidden. This is |
| + // stronger than following the current command, journal and checker roots. |
| + root := "../.." |
| + fileSet := token.NewFileSet() |
| + definitions := map[string]bool{} |
| + allowed := map[string]bool{} |
| + for _, path := range []string{"internal/home/schema_commitment_linux.go", "internal/operator/schema_protocol.go", "internal/operator/schema_command_linux.go", "internal/provisioner/schema_plan_linux.go"} { |
| + full := filepath.Clean(filepath.Join(root, path)) |
| + allowed[full] = true |
| + source, err := parser.ParseFile(fileSet, full, nil, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, decl := range source.Decls { |
| + switch d := decl.(type) { |
| + case *ast.FuncDecl: |
| + definitions[d.Name.Name] = true |
| + case *ast.GenDecl: |
| + for _, spec := range d.Specs { |
| + switch s := spec.(type) { |
| + case *ast.TypeSpec: |
| + definitions[s.Name.Name] = true |
| + case *ast.ValueSpec: |
| + for _, n := range s.Names { |
| + definitions[n.Name] = true |
| + } |
| + } |
| + } |
| + } |
| + } |
| + } |
| + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + return nil |
| + } |
| + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || allowed[filepath.Clean(path)] { |
| + return nil |
| + } |
| + f, err := parser.ParseFile(fileSet, path, nil, 0) |
| + if err != nil { |
| + return err |
| + } |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + if id, ok := n.(*ast.Ident); ok && definitions[id.Name] { |
| + t.Errorf("unmounted admission reference %s at %s", id.Name, fileSet.Position(id.Pos())) |
| + } |
| + return true |
| + }) |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestSchemaCommitmentGoldenDigests(t *testing.T) { |
| + for id, want := range map[string]string{ |
| + "credential-current-v1": "db65d14c48ef762b692d8a74f158e46e5d6117518092274dcefe6a1319d01451", |
| + "directory-current-v1": "344fca53f2cfc5eae5f1c05eb650c503c8af14cc98ec410b3146cfee8eebae02", |
| + "directory-historical-empty-v1": "1313fb93488e25164ed127927725194dc07c524be6219beedc162b4ce3588309", |
| + } { |
| + if selectedSchema(t, id).digest != want { |
| + t.Fatal("immutable manifest changed", id) |
| + } |
| + } |
| +} |
| + |
| +func TestSchemaCommitmentPreservesSessionsAndRejectsLaterOccupancy(t *testing.T) { |
| + d := legacyDiagnosticFixture(t, true, true) |
| + selected := selectedSchema(t, "directory-historical-empty-v1") |
| + if _, err := d.Writer().Exec("INSERT INTO sessions VALUES('hash','opaque-person','method','auth-time','created','expires')"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := checkSelectedSchema(t, d.Writer(), selected); err != nil { |
| + t.Fatal("shared session refused", err) |
| + } |
| + var subject string |
| + if err := d.Writer().QueryRow("SELECT subject FROM sessions WHERE token_hash='hash'").Scan(&subject); err != nil || subject != "opaque-person" { |
| + t.Fatal("session changed") |
| + } |
| + if _, err := d.Writer().Exec("INSERT INTO people VALUES('later','later','time')"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := checkSelectedSchema(t, d.Writer(), selected); err == nil { |
| + t.Fatal("earlier successful check authorized later occupancy") |
| + } |
| + conn, err := d.Writer().Conn(t.Context()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer conn.Close() |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + cancel() |
| + if err := checkSchemaCommitment(ctx, conn, selected); err == nil { |
| + t.Fatal("cancelled check accepted") |
| + } |
| +} |
diff --git a/internal/operator/schema_command_linux.go b/internal/operator/schema_command_linux.go| new file mode 100644 |
| index 0000000..e164e70 |
| --- /dev/null |
| +++ b/internal/operator/schema_command_linux.go |
| @@ -0,0 +1,23 @@ |
| +//go:build linux |
| + |
| +package operator |
| + |
| +import "amadan.net/carlos/platform.git/launchctx/controlname" |
| + |
| +type schemaCommandConfigV2 struct { |
| + commandConfig |
| + SchemaProfile string `json:"schema_profile"` |
| + SchemaManifestDigest string `json:"schema_manifest_digest"` |
| +} |
| + |
| +func parseSchemaCommandConfigV2(raw []byte) (schemaCommandConfigV2, *schemaVerifierV2, error) { |
| + var c schemaCommandConfigV2 |
| + if len(raw) == 0 || len(raw) > 8192 || strict(raw, &c) != nil || c.Version != 2 || !controlname.ValidAccount(c.Account) || !controlname.ValidApp(c.App) || !controlname.ValidHostShape(c.Host) { |
| + return schemaCommandConfigV2{}, nil, ErrRefused |
| + } |
| + v, err := newSchemaVerifierV2(Pins{c.Role, c.Origin, c.Installation, c.Registry, c.Device, c.PublicKey, c.Actions}, c.SchemaProfile, c.SchemaManifestDigest) |
| + if err != nil || c.KeyID != v.base.kid { |
| + return schemaCommandConfigV2{}, nil, ErrRefused |
| + } |
| + return c, v, nil |
| +} |
diff --git a/internal/operator/schema_command_linux_test.go b/internal/operator/schema_command_linux_test.go| new file mode 100644 |
| index 0000000..350f806 |
| --- /dev/null |
| +++ b/internal/operator/schema_command_linux_test.go |
| @@ -0,0 +1,37 @@ |
| +//go:build linux |
| + |
| +package operator |
| + |
| +import "testing" |
| + |
| +func TestSchemaCommandStaysOutsidePublicDispatch(t *testing.T) { |
| + _, _, v, a := schemaWireFixture(t) |
| + c := schemaCommandConfigV2{commandConfig{2, "bds", "home", "id.example.test", a.Role, a.Origin, a.Installation, a.Registry, a.Device, v.base.pins.PublicKey, v.base.kid, v.base.pins.Actions}, a.SchemaProfile, a.SchemaManifestDigest} |
| + raw, err := encode(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := parseSchemaCommandConfigV2(raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := parseCommandConfig(string(raw)); err == nil { |
| + t.Fatal("mounted config parser accepted v2") |
| + } |
| + old := c.commandConfig |
| + old.Version = 1 |
| + wire, err := encode(old) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := parseSchemaCommandConfigV2(wire); err == nil { |
| + t.Fatal("private v2 config parser accepted v1") |
| + } |
| + if _, _, err := parseCommandConfig(string(wire)); err != nil { |
| + t.Fatal("v1 config regressed", err) |
| + } |
| + c.Version = 1 |
| + wire, _ = encode(c) |
| + if _, _, err := parseCommandConfig(string(wire)); err == nil { |
| + t.Fatal("v1 accepted schema fields") |
| + } |
| +} |
diff --git a/internal/operator/schema_protocol.go b/internal/operator/schema_protocol.go| new file mode 100644 |
| index 0000000..162ab7c |
| --- /dev/null |
| +++ b/internal/operator/schema_protocol.go |
| @@ -0,0 +1,162 @@ |
| +package operator |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/hex" |
| + "time" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +// Private v2 wire types cannot enter the v1 engine or journal. The future |
| +// verified adapter must also match these pins against the embedded catalog. |
| +type schemaAuthorizationV2 struct { |
| + Authorization |
| + SchemaProfile string `json:"schema_profile"` |
| + SchemaManifestDigest string `json:"schema_manifest_digest"` |
| +} |
| +type schemaInspectV2 struct { |
| + InspectChallenge |
| + SchemaProfile string `json:"schema_profile"` |
| + SchemaManifestDigest string `json:"schema_manifest_digest"` |
| +} |
| +type schemaVerifierV2 struct { |
| + base *Verifier |
| + profile, manifestDigest string |
| +} |
| +type schemaRequestV2 struct { |
| + authorization schemaAuthorizationV2 |
| + input []byte |
| + head string |
| +} |
| + |
| +func validSchemaPinV2(role, profile, manifestDigest string) bool { |
| + sum, err := hex.DecodeString(manifestDigest) |
| + if err != nil || len(sum) != 32 || hex.EncodeToString(sum) != manifestDigest { |
| + return false |
| + } |
| + return role == "credential" && profile == "credential-current-v1" || |
| + role == "directory" && (profile == "directory-current-v1" || profile == "directory-historical-empty-v1") |
| +} |
| +func newSchemaVerifierV2(p Pins, profile, manifestDigest string) (*schemaVerifierV2, error) { |
| + if !validSchemaPinV2(p.Role, profile, manifestDigest) { |
| + return nil, ErrRefused |
| + } |
| + base, err := NewVerifier(p) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &schemaVerifierV2{base, profile, manifestDigest}, nil |
| +} |
| +func validSchemaAuthorizationV2(a schemaAuthorizationV2) bool { |
| + if a.Version != 2 || !validSchemaPinV2(a.Role, a.SchemaProfile, a.SchemaManifestDigest) { |
| + return false |
| + } |
| + base := a.Authorization |
| + base.Version = 1 |
| + return base.valid() |
| +} |
| +func validSchemaInspectV2(c schemaInspectV2) bool { |
| + if c.Version != 2 || !validSchemaPinV2(c.Role, c.SchemaProfile, c.SchemaManifestDigest) { |
| + return false |
| + } |
| + base := c.InspectChallenge |
| + base.Version = 1 |
| + return base.valid() |
| +} |
| +func signSchemaRequestV2(k *family.Keypair, a schemaAuthorizationV2, input any) ([]byte, error) { |
| + if !signingKey(k) { |
| + return nil, ErrRefused |
| + } |
| + b, err := encode(input) |
| + if err != nil || len(b) > InputLimit { |
| + return nil, ErrRefused |
| + } |
| + a.InputDigest = digest(b) |
| + if !validSchemaAuthorizationV2(a) { |
| + return nil, ErrRefused |
| + } |
| + payload, err := encode(a) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sig, err := family.Sign(k, InputContext, payload) |
| + if err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + env, err := encode(envelope{1, keyID(k.SignPub()), base64.RawURLEncoding.EncodeToString(payload), base64.RawURLEncoding.EncodeToString(sig)}) |
| + if err != nil || len(env) > AuthorizationLimit { |
| + return nil, ErrRefused |
| + } |
| + out, err := encode(wrapper{2, env, b}) |
| + if err != nil || len(out) > BodyLimit { |
| + return nil, ErrRefused |
| + } |
| + return out, nil |
| +} |
| +func verifySchemaRequestV2(v *schemaVerifierV2, raw []byte) (schemaRequestV2, error) { |
| + fail := func() (schemaRequestV2, error) { return schemaRequestV2{}, ErrRefused } |
| + if v == nil || v.base == nil || len(raw) == 0 || len(raw) > BodyLimit { |
| + return fail() |
| + } |
| + var w wrapper |
| + if strictCanonical(raw, &w) != nil || w.Version != 2 || len(w.Authorization) == 0 || len(w.Authorization) > AuthorizationLimit || len(w.Input) == 0 || len(w.Input) > InputLimit { |
| + return fail() |
| + } |
| + var env envelope |
| + if strictCanonical(w.Authorization, &env) != nil || env.Version != 1 || env.KeyID != v.base.kid { |
| + return fail() |
| + } |
| + payload, ok := canonical(env.Payload, -1) |
| + sig, ok2 := canonical(env.Signature, 64) |
| + if !ok || !ok2 || len(payload) > AuthorizationLimit || !family.Verify(v.base.key, InputContext, payload, sig) { |
| + return fail() |
| + } |
| + var a schemaAuthorizationV2 |
| + if strictCanonical(payload, &a) != nil || !validSchemaAuthorizationV2(a) { |
| + return fail() |
| + } |
| + p := v.base.pins |
| + if !v.base.actions[a.Action] || a.Role != p.Role || a.Origin != p.Origin || a.Installation != p.Installation || a.Registry != p.Registry || a.Device != p.Device || a.InputDigest != digest(w.Input) || a.SchemaProfile != v.profile || a.SchemaManifestDigest != v.manifestDigest { |
| + return fail() |
| + } |
| + return schemaRequestV2{a, append([]byte(nil), w.Input...), digest(w.Authorization)}, nil |
| +} |
| +func signSchemaInspectV2(k *family.Keypair, c schemaInspectV2) ([]byte, error) { |
| + if !signingKey(k) || !validSchemaInspectV2(c) { |
| + return nil, ErrRefused |
| + } |
| + payload, err := encode(c) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sig, err := family.Sign(k, InspectContext, payload) |
| + if err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return encode(envelope{1, keyID(k.SignPub()), base64.RawURLEncoding.EncodeToString(payload), base64.RawURLEncoding.EncodeToString(sig)}) |
| +} |
| +func verifySchemaInspectV2(v *schemaVerifierV2, raw []byte, now time.Time) (schemaInspectV2, error) { |
| + fail := func() (schemaInspectV2, error) { return schemaInspectV2{}, ErrRefused } |
| + if v == nil || v.base == nil || len(raw) == 0 || len(raw) > AuthorizationLimit { |
| + return fail() |
| + } |
| + var env envelope |
| + if strictCanonical(raw, &env) != nil || env.Version != 1 || env.KeyID != v.base.kid { |
| + return fail() |
| + } |
| + payload, ok := canonical(env.Payload, -1) |
| + sig, ok2 := canonical(env.Signature, 64) |
| + if !ok || !ok2 || len(payload) > AuthorizationLimit || !family.Verify(v.base.key, InspectContext, payload, sig) { |
| + return fail() |
| + } |
| + var c schemaInspectV2 |
| + if strictCanonical(payload, &c) != nil || !validSchemaInspectV2(c) { |
| + return fail() |
| + } |
| + p := v.base.pins |
| + if !v.base.actions["directory.inspect"] || c.Role != p.Role || c.Origin != p.Origin || c.Installation != p.Installation || c.Registry != p.Registry || c.Device != p.Device || c.SchemaProfile != v.profile || c.SchemaManifestDigest != v.manifestDigest || c.Issued > now.Unix() || c.Expires <= now.Unix() { |
| + return fail() |
| + } |
| + return c, nil |
| +} |
diff --git a/internal/operator/schema_protocol_test.go b/internal/operator/schema_protocol_test.go| new file mode 100644 |
| index 0000000..ef66fee |
| --- /dev/null |
| +++ b/internal/operator/schema_protocol_test.go |
| @@ -0,0 +1,188 @@ |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +func schemaWireFixture(t *testing.T) (*family.Keypair, *Verifier, *schemaVerifierV2, schemaAuthorizationV2) { |
| + t.Helper() |
| + key, old, a := fixture(t) |
| + a.Version = 2 |
| + p := old.pins |
| + p.Actions = append(p.Actions, "directory.inspect") |
| + v, err := newSchemaVerifierV2(p, "directory-historical-empty-v1", strings.Repeat("a", 64)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return key, old, v, schemaAuthorizationV2{a, v.profile, v.manifestDigest} |
| +} |
| +func TestSchemaWireVersionsAndSignedPins(t *testing.T) { |
| + key, old, v, a := schemaWireFixture(t) |
| + raw, err := signSchemaRequestV2(key, a, struct { |
| + Generation string `json:"generation"` |
| + }{token(8)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err := verifySchemaRequestV2(v, raw) |
| + if err != nil || r.authorization.Version != 2 || r.head == "" { |
| + t.Fatal(err) |
| + } |
| + if _, err := old.Verify(raw); err == nil { |
| + t.Fatal("public v1 verifier accepted v2") |
| + } |
| + oldAuth := a.Authorization |
| + oldAuth.Version = 1 |
| + if _, err := verifySchemaRequestV2(v, signed(t, key, oldAuth)); err == nil { |
| + t.Fatal("v2 accepted v1") |
| + } |
| + if _, err := SignRequest(key, a.Authorization, map[string]string{}); err == nil { |
| + t.Fatal("public signer accepted v2") |
| + } |
| + for _, change := range []func(*schemaAuthorizationV2){ |
| + func(a *schemaAuthorizationV2) { a.SchemaProfile = "directory-current-v1" }, |
| + func(a *schemaAuthorizationV2) { a.SchemaManifestDigest = strings.Repeat("b", 64) }, |
| + func(a *schemaAuthorizationV2) { a.Origin = "https://other.example.test" }, |
| + func(a *schemaAuthorizationV2) { a.Installation = token(19) }, |
| + func(a *schemaAuthorizationV2) { a.Registry = token(19) }, |
| + func(a *schemaAuthorizationV2) { a.Device = token(19) }, |
| + func(a *schemaAuthorizationV2) { a.Action = "directory.activate-consumer" }, |
| + } { |
| + changed := a |
| + change(&changed) |
| + wire, err := signSchemaRequestV2(key, changed, map[string]string{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := verifySchemaRequestV2(v, wire); err == nil { |
| + t.Fatal("trusted key authorized another target/profile") |
| + } |
| + } |
| + for _, bad := range []schemaAuthorizationV2{ |
| + {a.Authorization, "", a.SchemaManifestDigest}, |
| + {a.Authorization, "unknown", a.SchemaManifestDigest}, |
| + {a.Authorization, a.SchemaProfile, ""}, |
| + {a.Authorization, a.SchemaProfile, strings.Repeat("A", 64)}, |
| + } { |
| + if _, err := signSchemaRequestV2(key, bad, map[string]string{}); err == nil { |
| + t.Fatal("invalid commitment signed") |
| + } |
| + } |
| + var w wrapper |
| + if strictCanonical(raw, &w) != nil { |
| + t.Fatal("wrapper") |
| + } |
| + for _, version := range []int{0, 1, 3} { |
| + changed := w |
| + changed.Version = version |
| + wire, _ := encode(changed) |
| + if _, err := verifySchemaRequestV2(v, wire); err == nil { |
| + t.Fatal("mixed wrapper accepted") |
| + } |
| + } |
| + var env envelope |
| + if strictCanonical(w.Authorization, &env) != nil { |
| + t.Fatal("envelope") |
| + } |
| + env.Version = 2 |
| + changed := w |
| + changed.Authorization, _ = encode(env) |
| + wire, _ := encode(changed) |
| + if _, err := verifySchemaRequestV2(v, wire); err == nil { |
| + t.Fatal("envelope v2 accepted") |
| + } |
| + changed = w |
| + changed.Input = []byte(`{"generation":"changed"}`) |
| + wire, _ = encode(changed) |
| + if _, err := verifySchemaRequestV2(v, wire); err == nil { |
| + t.Fatal("changed input accepted") |
| + } |
| + if _, err := verifySchemaRequestV2(v, append([]byte{' '}, raw...)); err == nil { |
| + t.Fatal("noncanonical wire") |
| + } |
| + if _, err := verifySchemaRequestV2(v, bytes.Replace(raw, []byte(`"version":2`), []byte(`"version":2,"version":2`), 1)); err == nil { |
| + t.Fatal("duplicate member") |
| + } |
| +} |
| +func TestSchemaInspectUsesOwnContextAndFreshPins(t *testing.T) { |
| + key, old, v, a := schemaWireFixture(t) |
| + c := schemaInspectV2{InspectChallenge{2, "operator-inspect", a.Role, a.Origin, a.Installation, a.Registry, a.Device, token(6), a.Issued, a.Expires}, a.SchemaProfile, a.SchemaManifestDigest} |
| + raw, err := signSchemaInspectV2(key, c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Unix(a.Issued, 0) |
| + if _, err := verifySchemaInspectV2(v, raw, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := old.verifyInspect(raw, now); err == nil { |
| + t.Fatal("v1 inspector accepted v2") |
| + } |
| + if _, err := SignInspect(key, c.InspectChallenge); err == nil { |
| + t.Fatal("public inspection signer accepted v2") |
| + } |
| + for _, at := range []int64{a.Issued - 1, a.Expires, a.Expires + 1} { |
| + if _, err := verifySchemaInspectV2(v, raw, time.Unix(at, 0)); err == nil { |
| + t.Fatal("stale inspection accepted") |
| + } |
| + } |
| + changed := c |
| + changed.SchemaProfile = "directory-current-v1" |
| + wire, _ := signSchemaInspectV2(key, changed) |
| + if _, err := verifySchemaInspectV2(v, wire, now); err == nil { |
| + t.Fatal("other signed profile accepted") |
| + } |
| + var env envelope |
| + if strictCanonical(raw, &env) != nil { |
| + t.Fatal("envelope") |
| + } |
| + payload, _ := base64.RawURLEncoding.DecodeString(env.Payload) |
| + sig, err := family.Sign(key, InputContext, payload) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + env.Signature = base64.RawURLEncoding.EncodeToString(sig) |
| + wire, _ = encode(env) |
| + if _, err := verifySchemaInspectV2(v, wire, now); err == nil { |
| + t.Fatal("inspection signed for mutation context accepted") |
| + } |
| + oldChallenge := c.InspectChallenge |
| + oldChallenge.Version = 1 |
| + wire, err = SignInspect(key, oldChallenge) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := verifySchemaInspectV2(v, wire, now); err == nil { |
| + t.Fatal("v2 inspector accepted v1") |
| + } |
| +} |
| + |
| +func TestSchemaWireKeepsV1PayloadBytes(t *testing.T) { |
| + _, _, a := fixture(t) |
| + a.InputDigest = token(7) |
| + c := InspectChallenge{1, "operator-inspect", a.Role, a.Origin, a.Installation, a.Registry, a.Device, token(6), a.Issued, a.Expires} |
| + for name, value := range map[string]any{"authorization": a, "inspection": c} { |
| + raw, err := encode(value) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + path := "testdata/schema-v1-" + name + ".json" |
| + if os.Getenv("OFICINA_CAPTURE_V1_SCHEMA_WIRE") == "1" { |
| + if err := os.WriteFile(path, raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + continue |
| + } |
| + frozen, err := os.ReadFile(path) |
| + if err != nil || !bytes.Equal(frozen, raw) { |
| + t.Fatal("v1 signature input changed", name, err) |
| + } |
| + } |
| +} |
diff --git a/internal/operator/testdata/schema-v1-authorization.json b/internal/operator/testdata/schema-v1-authorization.json| new file mode 100644 |
| index 0000000..5d5fb7a |
| --- /dev/null |
| +++ b/internal/operator/testdata/schema-v1-authorization.json |
| @@ -0,0 +1 @@ |
| +{"version":1,"purpose":"operator-input","role":"directory","origin":"https://oficina.works","installation":"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","registry":"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","device":"AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM","sequence":1,"initial_authority":true,"previous":"","journal_sequence":1,"operation_id":"BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ","action":"directory.initialize","input_digest":"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc","issued_at":2000000000,"expires_at":2000000300} |
| \ No newline at end of file |
diff --git a/internal/operator/testdata/schema-v1-inspection.json b/internal/operator/testdata/schema-v1-inspection.json| new file mode 100644 |
| index 0000000..32ee18f |
| --- /dev/null |
| +++ b/internal/operator/testdata/schema-v1-inspection.json |
| @@ -0,0 +1 @@ |
| +{"version":1,"purpose":"operator-inspect","role":"directory","origin":"https://oficina.works","installation":"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","registry":"AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI","device":"AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM","challenge":"BgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgY","issued_at":2000000000,"expires_at":2000000300} |
| \ No newline at end of file |
diff --git a/internal/provisioner/schema_plan_linux.go b/internal/provisioner/schema_plan_linux.go| new file mode 100644 |
| index 0000000..5c5bc6c |
| --- /dev/null |
| +++ b/internal/provisioner/schema_plan_linux.go |
| @@ -0,0 +1,36 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "amadan.net/oficina/home/internal/operator" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +type schemaTargetV2 struct { |
| + InstallationTarget |
| + SchemaProfile string `json:"schema_profile"` |
| + SchemaManifestDigest string `json:"schema_manifest_digest"` |
| +} |
| +type schemaPlanV2 struct { |
| + Version int `json:"version"` |
| + Installation string `json:"installation"` |
| + BaselineOperation string `json:"baseline_operation"` |
| + Credential schemaTargetV2 `json:"credential"` |
| + Directory schemaTargetV2 `json:"directory"` |
| +} |
| + |
| +// This decoder does not enroll, sign or persist a plan. The v1 journal refuses it. |
| +func decodeSchemaPlanV2(raw []byte) (schemaPlanV2, error) { |
| + var p schemaPlanV2 |
| + if len(raw) == 0 || len(raw) > 64<<10 || json.Unmarshal(raw, &p, json.RejectUnknownMembers(true)) != nil || p.Version != 2 { |
| + return schemaPlanV2{}, ErrRefused |
| + } |
| + old := InstallationPlan{1, p.Installation, p.BaselineOperation, p.Credential.InstallationTarget, p.Directory.InstallationTarget} |
| + if !old.valid() || !operator.CanonicalToken(p.Installation) || p.Credential.SchemaProfile != "credential-current-v1" || |
| + (p.Directory.SchemaProfile != "directory-current-v1" && p.Directory.SchemaProfile != "directory-historical-empty-v1") || |
| + !validHex(p.Credential.SchemaManifestDigest, 64) || !validHex(p.Directory.SchemaManifestDigest, 64) { |
| + return schemaPlanV2{}, ErrRefused |
| + } |
| + return p, nil |
| +} |
diff --git a/internal/provisioner/schema_plan_linux_test.go b/internal/provisioner/schema_plan_linux_test.go| new file mode 100644 |
| index 0000000..7c12471 |
| --- /dev/null |
| +++ b/internal/provisioner/schema_plan_linux_test.go |
| @@ -0,0 +1,55 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +func TestSchemaPlanV2IsLocalAndNotEnrollable(t *testing.T) { |
| + old := planFixture() |
| + p := schemaPlanV2{2, old.Installation, old.BaselineOperation, |
| + schemaTargetV2{old.Credential, "credential-current-v1", strings.Repeat("a", 64)}, |
| + schemaTargetV2{old.Directory, "directory-historical-empty-v1", strings.Repeat("b", 64)}} |
| + raw, err := json.Marshal(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + decoded, err := decodeSchemaPlanV2(raw) |
| + if err != nil || decoded != p { |
| + t.Fatal(err) |
| + } |
| + for _, change := range []func(*schemaPlanV2){ |
| + func(p *schemaPlanV2) { p.Version = 1 }, |
| + func(p *schemaPlanV2) { p.Credential.SchemaProfile = "directory-historical-empty-v1" }, |
| + func(p *schemaPlanV2) { p.Directory.SchemaManifestDigest = "" }, |
| + func(p *schemaPlanV2) { p.Directory.SchemaManifestDigest = strings.Repeat("B", 64) }, |
| + func(p *schemaPlanV2) { p.Directory.SchemaProfile = "unknown" }, |
| + func(p *schemaPlanV2) { p.Directory.Host = p.Credential.Host }, |
| + } { |
| + changed := p |
| + change(&changed) |
| + wire, _ := json.Marshal(changed) |
| + if _, err := decodeSchemaPlanV2(wire); err == nil { |
| + t.Fatal("invalid v2 plan accepted") |
| + } |
| + } |
| + wire, _ := json.Marshal(old) |
| + if _, err := decodeSchemaPlanV2(wire); err == nil { |
| + t.Fatal("v2 accepted v1 plan") |
| + } |
| + var legacy InstallationPlan |
| + if json.Unmarshal(raw, &legacy, json.RejectUnknownMembers(true)) == nil { |
| + t.Fatal("old wire decoder accepted v2 fields") |
| + } |
| + j := journalFixture(t) |
| + legacy = old |
| + legacy.Version = 2 |
| + if err := j.enroll(context.Background(), legacy); err == nil { |
| + t.Fatal("journal enrolled v2") |
| + } |
| +} |