oficina / home Public

Add unmounted schema commitments and v2 provisioning wire primitives

Paul Campbell pushed by paul@keymail.dev f88ea26453ea9440e7af9a4ff811207028dcfa93 parent 12f4cf3
13 files changed, +2133 −0
  • internal/home/admission_profiles/credential-current-v1.json +191 −0
  • internal/home/admission_profiles/directory-current-v1.json +391 −0
  • internal/home/admission_profiles/directory-historical-empty-v1.json +506 −0
  • internal/home/schema_commitment_linux.go +248 −0
  • internal/home/schema_commitment_linux_test.go +294 −0
  • internal/operator/schema_command_linux.go +23 −0
  • internal/operator/schema_command_linux_test.go +37 −0
  • internal/operator/schema_protocol.go +162 −0
  • internal/operator/schema_protocol_test.go +188 −0
  • internal/operator/testdata/schema-v1-authorization.json +1 −0
  • internal/operator/testdata/schema-v1-inspection.json +1 −0
  • internal/provisioner/schema_plan_linux.go +36 −0
  • internal/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")
+ }
+}