myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 9368d381f9b470e855396a586e078e8ff33d497d
parent e0a68ea7a9c778e6eece540ed1053a2406b436d3
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 16:38:46 +0000

state: add connection authorization state

Diffstat:
MREADME | 19++++++++++++++++---
Msrc/lib.rs | 18++++++++++++++++--
Msrc/state_catalog.rs | 500+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/state_connection.rs | 2021+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_host.rs | 4++--
Msrc/state_request.rs | 8+++++++-
Atests/services_hardening_connection_state.rs | 929+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_signer_request_state.rs | 32++++++++++++++++++++------------
Mtests/services_hardening_state_catalog.rs | 55++++++++++++++++++++++++++++++++++++++++++++-----------
Mtests/services_hardening_state_repository.rs | 12++++++++++--
Mtests/services_hardening_state_resilience.rs | 20+++++++++++++-------
11 files changed, 3567 insertions(+), 51 deletions(-)

diff --git a/README b/README @@ -47,17 +47,18 @@ without changing the canonical common artifact inventory. Create-new initialization reserves the shared schema-v1 metadata and migration ledger, retains exclusive writer authority, applies the exact Myc schema-v2 -and schema-v3 migrations, binds the normalized configuration, expected +through schema-v4 migrations, binds the normalized configuration, expected identity roles, and policy versions through a sealed typed repository, and explicitly closes the host before reporting success. Existing writable open can -resume the exact v1 or v2 prefix; read-only inspection requires the current +resume the exact v1, v2, or v3 prefix; read-only inspection requires the current catalog and exact immutable Myc binding. The public Myc repository exposes no raw pool, connection, transaction-control handle, path, or SQL. Binding and request-admission mutations execute only inside the shared `ServiceSqliteTransaction` runner. Provider and relay work cannot occur inside that transaction boundary. The v2 binding table, v3 -request/dedup tables, and their update guards are checksum-pinned service-owned +request/dedup tables, and v4 connection, permission, request-decision, and +authorization-challenge tables and guards are checksum-pinned service-owned schema objects; later workflow tables remain owned by their ordered repository steps. @@ -72,6 +73,18 @@ decrypted request bytes. Replay and conflict counters are bounded and survive explicit close and reopen; retention remains owned by its later state checkpoint. +Connection admission consumes an already-admitted `connect` operation and an +explicit policy generation. Trusted admission creates an active connection; +explicit approval creates a pending connection that can transition once to an +operator-approved or operator-denied terminal decision; direct policy denial +records a durable decision without creating a connection or approval workflow. +Requested and granted permissions are closed, bounded, and independently +bound. Authorization challenges are issued only for a non-connect operation +bound to an active connection, use an operator-owned canonical URL plus +injected entropy and time, and transition once to authorized or expired. +Exact retries return the original durable identity or terminal state, including +after explicit close and reopen. + Writable hosts expose Myc-bound online-backup and active-integrity operations. Backup verification retains the exact admitted member inode, and offline staging derives the same runtime paths, database identity, migration diff --git a/src/lib.rs b/src/lib.rs @@ -26,6 +26,7 @@ pub mod signer; mod signing_adapter; pub mod sql; mod state_catalog; +mod state_connection; mod state_host; mod state_maintenance; mod state_metadata; @@ -116,8 +117,21 @@ pub use state_catalog::{ MYC_STATE_SCHEMA_VERSION_1_SHA256, MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_2_SHA256, MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_3_OBJECT_COUNT, - MYC_STATE_SCHEMA_VERSION_3_SHA256, MycStateCatalogError, MycStateCatalogErrorKind, - myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs, + MYC_STATE_SCHEMA_VERSION_3_SHA256, MYC_STATE_SCHEMA_VERSION_4_MIGRATION_SHA256, + MYC_STATE_SCHEMA_VERSION_4_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_4_SHA256, + MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, + validate_myc_state_catalogs, +}; +pub use state_connection::{ + MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES, MYC_CONNECTION_PERMISSION_MAX_COUNT, + MycAuthorizationChallengeAdmission, MycAuthorizationChallengeId, + MycAuthorizationChallengeNonce, MycAuthorizationChallengeRecord, + MycAuthorizationChallengeRequest, MycAuthorizationChallengeState, MycAuthorizationChallengeUrl, + MycConnectionAdmission, MycConnectionAdmissionPolicy, MycConnectionAdmissionRequest, + MycConnectionDecision, MycConnectionDecisionRecord, MycConnectionId, MycConnectionNonce, + MycConnectionOperatorDecision, MycConnectionPermission, MycConnectionPermissionSet, + MycConnectionPolicyGeneration, MycConnectionRecord, MycConnectionStateError, + MycConnectionStateErrorKind, MycConnectionStatus, MycConnectionTimeUnixMs, }; pub use state_host::{ MycStateHost, MycStateHostError, MycStateHostErrorKind, MycStateHostMode, initialize_myc_state, diff --git a/src/state_catalog.rs b/src/state_catalog.rs @@ -12,7 +12,7 @@ use radroots_service_sqlite::{ pub const MYC_STATE_BASE_SCHEMA_VERSION: u32 = 1; /// The newest governed Myc state schema understood by this binary. -pub const MYC_STATE_SCHEMA_VERSION: u32 = 3; +pub const MYC_STATE_SCHEMA_VERSION: u32 = 4; /// The shared metadata and migration-ledger objects present at schema v1. pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; @@ -23,6 +23,9 @@ pub const MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT: u32 = 9; /// The shared objects plus Myc metadata and request-admission objects at schema v3. pub const MYC_STATE_SCHEMA_VERSION_3_OBJECT_COUNT: u32 = 13; +/// The shared objects plus Myc metadata, request, and connection objects at schema v4. +pub const MYC_STATE_SCHEMA_VERSION_4_OBJECT_COUNT: u32 = 25; + /// SHA-256 identity of the exact schema-v1 object snapshot. pub const MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [ 0x94, 0xdc, 0x66, 0xfb, 0xca, 0x60, 0x16, 0x79, 0x61, 0x5c, 0x05, 0x52, 0x29, 0xdc, 0x0d, 0xb6, @@ -37,14 +40,14 @@ pub const MYC_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32] = [ /// SHA-256 identity of the ordered Myc migration catalog. pub const MYC_MIGRATION_CATALOG_SHA256: [u8; 32] = [ - 0x3d, 0x79, 0xb7, 0x19, 0xea, 0x3f, 0xe4, 0x63, 0xe2, 0x66, 0xf5, 0xed, 0x0d, 0x1f, 0x09, 0x1e, - 0x3c, 0x33, 0x17, 0x7c, 0x17, 0x82, 0x0d, 0x21, 0xbd, 0x85, 0x96, 0xdf, 0xbd, 0x31, 0xaa, 0x9e, + 0x45, 0x3d, 0x99, 0xf4, 0xc1, 0x9c, 0x09, 0x4c, 0x59, 0x2f, 0x1a, 0x3f, 0xe7, 0xe2, 0x8e, 0x82, + 0xc1, 0xde, 0xa6, 0x74, 0x51, 0x4a, 0x9e, 0x7d, 0x06, 0x3b, 0xc4, 0x66, 0xc2, 0x0b, 0xd4, 0xbd, ]; /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x26, 0x55, 0x64, 0xd0, 0x95, 0x67, 0x72, 0x4f, 0xac, 0x62, 0x1d, 0x1e, 0x00, 0xc3, 0x7d, 0xbc, - 0xcb, 0xe3, 0xcc, 0x24, 0xa9, 0xfa, 0xb0, 0x0e, 0x59, 0xae, 0x70, 0xc6, 0xfe, 0x89, 0x87, 0x2b, + 0xa4, 0x7d, 0x9f, 0x0a, 0xf8, 0x04, 0x20, 0x2b, 0xfa, 0x07, 0x26, 0x8e, 0x08, 0xfb, 0x48, 0x4d, + 0xed, 0x59, 0x0b, 0x78, 0x5c, 0xc4, 0x2a, 0x01, 0x09, 0x4d, 0x1a, 0x8b, 0x9f, 0x37, 0xee, 0xca, ]; /// SHA-256 identity of the schema-v2 migration content. @@ -65,6 +68,18 @@ pub const MYC_STATE_SCHEMA_VERSION_3_SHA256: [u8; 32] = [ 0x58, 0x48, 0x8f, 0xb8, 0xab, 0xeb, 0xa0, 0xa3, 0x4b, 0xa6, 0x9b, 0x4b, 0x70, 0x80, 0xba, 0x08, ]; +/// SHA-256 identity of the schema-v4 migration content. +pub const MYC_STATE_SCHEMA_VERSION_4_MIGRATION_SHA256: [u8; 32] = [ + 0x93, 0x9c, 0x0e, 0xd0, 0x7c, 0xd1, 0x5c, 0xc0, 0xc7, 0x94, 0xbf, 0x6c, 0x2a, 0xf7, 0x19, 0x22, + 0x61, 0x40, 0x93, 0x05, 0xc2, 0x87, 0x6f, 0x3b, 0xe3, 0x63, 0xe8, 0xe4, 0xfe, 0x4a, 0x1a, 0xbb, +]; + +/// SHA-256 identity of the schema-v4 object snapshot. +pub const MYC_STATE_SCHEMA_VERSION_4_SHA256: [u8; 32] = [ + 0x47, 0x98, 0x63, 0xd3, 0x7d, 0x91, 0xe6, 0xc2, 0x69, 0xfa, 0x35, 0x73, 0xdb, 0x6c, 0x2e, 0x76, + 0x7c, 0xdd, 0x3b, 0x24, 0xa9, 0x3a, 0xb4, 0x82, 0xd7, 0x74, 0xbc, 0xec, 0x02, 0x18, 0xc1, 0x74, +]; + /// SHA-256 identity of the Myc metadata table definition. const MYC_STATE_METADATA_TABLE_SHA256: [u8; 32] = [ 0x16, 0x17, 0x46, 0xa2, 0x26, 0x42, 0x46, 0x2f, 0x2b, 0xdb, 0x08, 0x5b, 0xae, 0xde, 0xb2, 0x3b, @@ -269,6 +284,338 @@ const CREATE_NIP46_REQUEST_ADMISSION_MIGRATION_SQL: &str = concat!( nip46_request_dedup_guard_update_sql!(), ); +macro_rules! connections_table_sql { + () => { + r#"CREATE TABLE connections ( + connection_id BLOB NOT NULL PRIMARY KEY CHECK (length(connection_id) = 32), + connection_nonce BLOB NOT NULL CHECK (length(connection_nonce) = 32), + client_public_key TEXT NOT NULL + CHECK (length(CAST(client_public_key AS BLOB)) = 64) + CHECK (client_public_key NOT GLOB '*[^0-9a-f]*'), + requested_permissions_sha256 BLOB NOT NULL + CHECK (length(requested_permissions_sha256) = 32), + policy_generation INTEGER NOT NULL + CHECK (policy_generation BETWEEN 1 AND 9223372036854775807), + status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'denied', 'expired')), + created_at_unix_ms INTEGER NOT NULL + CHECK (created_at_unix_ms BETWEEN 1 AND 9223372036854775807), + updated_at_unix_ms INTEGER NOT NULL + CHECK (updated_at_unix_ms BETWEEN created_at_unix_ms AND 9223372036854775807), + authorized_until_unix_ms INTEGER + CHECK (authorized_until_unix_ms IS NULL OR + authorized_until_unix_ms BETWEEN created_at_unix_ms + 1 AND 9223372036854775807), + CHECK ((status = 'active') OR authorized_until_unix_ms IS NULL) +) STRICT"# + }; +} + +macro_rules! connection_permissions_table_sql { + () => { + r#"CREATE TABLE connection_permissions ( + connection_id BLOB NOT NULL CHECK (length(connection_id) = 32) + REFERENCES connections(connection_id), + permission_scope TEXT NOT NULL CHECK (permission_scope IN ('requested', 'granted')), + permission_code TEXT NOT NULL + CHECK (length(CAST(permission_code AS BLOB)) BETWEEN 1 AND 64), + PRIMARY KEY (connection_id, permission_scope, permission_code) +) STRICT"# + }; +} + +macro_rules! nip46_request_decisions_table_sql { + () => { + r#"CREATE TABLE nip46_request_decisions ( + operation_id BLOB NOT NULL PRIMARY KEY CHECK (length(operation_id) = 32) + REFERENCES nip46_requests(operation_id), + connection_id BLOB CHECK (connection_id IS NULL OR length(connection_id) = 32) + REFERENCES connections(connection_id), + decision TEXT NOT NULL + CHECK (decision IN ('pending_approval', 'challenged', 'allowed', 'denied')), + reason_code TEXT NOT NULL CHECK (reason_code IN ( + 'explicit_approval_required', + 'trusted_client', + 'policy_denied', + 'operator_approved', + 'operator_denied', + 'authorization_challenge_required', + 'authorization_challenge_authorized', + 'authorization_challenge_expired' + )), + policy_generation INTEGER NOT NULL + CHECK (policy_generation BETWEEN 1 AND 9223372036854775807), + requested_permissions_sha256 BLOB NOT NULL + CHECK (length(requested_permissions_sha256) = 32), + challenge_id BLOB UNIQUE CHECK (challenge_id IS NULL OR length(challenge_id) = 32) + REFERENCES connection_auth_challenges(challenge_id), + decided_at_unix_ms INTEGER NOT NULL + CHECK (decided_at_unix_ms BETWEEN 1 AND 9223372036854775807), + CHECK ( + (decision = 'denied' AND reason_code = 'policy_denied' + AND connection_id IS NULL AND challenge_id IS NULL) + OR (decision = 'pending_approval' AND reason_code = 'explicit_approval_required' + AND connection_id IS NOT NULL AND challenge_id IS NULL) + OR (decision = 'allowed' AND reason_code IN ('trusted_client', 'operator_approved') + AND connection_id IS NOT NULL AND challenge_id IS NULL) + OR (decision = 'denied' AND reason_code = 'operator_denied' + AND connection_id IS NOT NULL AND challenge_id IS NULL) + OR (decision = 'challenged' AND reason_code = 'authorization_challenge_required' + AND connection_id IS NOT NULL AND challenge_id IS NOT NULL) + OR (decision = 'allowed' AND reason_code = 'authorization_challenge_authorized' + AND connection_id IS NOT NULL AND challenge_id IS NOT NULL) + OR (decision = 'denied' AND reason_code = 'authorization_challenge_expired' + AND connection_id IS NOT NULL AND challenge_id IS NOT NULL) + ) +) STRICT"# + }; +} + +macro_rules! connection_auth_challenges_table_sql { + () => { + r#"CREATE TABLE connection_auth_challenges ( + challenge_id BLOB NOT NULL PRIMARY KEY CHECK (length(challenge_id) = 32), + challenge_nonce BLOB NOT NULL CHECK (length(challenge_nonce) = 32), + connection_id BLOB NOT NULL CHECK (length(connection_id) = 32) + REFERENCES connections(connection_id), + operation_id BLOB NOT NULL UNIQUE CHECK (length(operation_id) = 32) + REFERENCES nip46_requests(operation_id), + policy_generation INTEGER NOT NULL + CHECK (policy_generation BETWEEN 1 AND 9223372036854775807), + challenge_url TEXT NOT NULL + CHECK (length(CAST(challenge_url AS BLOB)) BETWEEN 1 AND 2048), + state TEXT NOT NULL CHECK (state IN ('pending', 'authorized', 'expired')), + issued_at_unix_ms INTEGER NOT NULL + CHECK (issued_at_unix_ms BETWEEN 1 AND 9223372036854775807), + expires_at_unix_ms INTEGER NOT NULL + CHECK (expires_at_unix_ms BETWEEN issued_at_unix_ms + 1 AND 9223372036854775807), + resolved_at_unix_ms INTEGER + CHECK (resolved_at_unix_ms IS NULL OR + resolved_at_unix_ms BETWEEN issued_at_unix_ms AND 9223372036854775807), + CHECK ((state = 'pending' AND resolved_at_unix_ms IS NULL) + OR (state IN ('authorized', 'expired') AND resolved_at_unix_ms IS NOT NULL)) +) STRICT"# + }; +} + +macro_rules! connections_guard_update_sql { + () => { + r#"CREATE TRIGGER connections_guard_update +BEFORE UPDATE ON connections +WHEN NEW.connection_id != OLD.connection_id + OR NEW.connection_nonce != OLD.connection_nonce + OR NEW.client_public_key != OLD.client_public_key + OR NEW.requested_permissions_sha256 != OLD.requested_permissions_sha256 + OR NEW.policy_generation != OLD.policy_generation + OR NEW.created_at_unix_ms != OLD.created_at_unix_ms + OR NEW.updated_at_unix_ms < OLD.updated_at_unix_ms + OR NOT ( + (OLD.status = 'pending' AND NEW.status = 'active' + AND (NEW.authorized_until_unix_ms IS NULL + OR NEW.authorized_until_unix_ms > NEW.updated_at_unix_ms)) + OR (OLD.status = 'pending' AND NEW.status = 'denied' + AND NEW.authorized_until_unix_ms IS NULL) + OR (OLD.status = 'active' AND NEW.status = 'expired' + AND NEW.authorized_until_unix_ms IS NULL) + ) +BEGIN + SELECT RAISE(ABORT, 'connection transition is invalid'); +END"# + }; +} + +macro_rules! connections_no_delete_sql { + () => { + r#"CREATE TRIGGER connections_no_delete +BEFORE DELETE ON connections +BEGIN + SELECT RAISE(ABORT, 'connection evidence is retained'); +END"# + }; +} + +macro_rules! connection_permissions_no_update_sql { + () => { + r#"CREATE TRIGGER connection_permissions_no_update +BEFORE UPDATE ON connection_permissions +BEGIN + SELECT RAISE(ABORT, 'connection permission evidence is immutable'); +END"# + }; +} + +macro_rules! connection_permissions_no_delete_sql { + () => { + r#"CREATE TRIGGER connection_permissions_no_delete +BEFORE DELETE ON connection_permissions +BEGIN + SELECT RAISE(ABORT, 'connection permission evidence is retained'); +END"# + }; +} + +macro_rules! nip46_request_decisions_guard_update_sql { + () => { + r#"CREATE TRIGGER nip46_request_decisions_guard_update +BEFORE UPDATE ON nip46_request_decisions +WHEN NEW.operation_id != OLD.operation_id + OR NEW.connection_id IS NOT OLD.connection_id + OR NEW.policy_generation != OLD.policy_generation + OR NEW.requested_permissions_sha256 != OLD.requested_permissions_sha256 + OR NEW.challenge_id IS NOT OLD.challenge_id + OR NEW.decided_at_unix_ms < OLD.decided_at_unix_ms + OR NOT ( + (OLD.decision = 'pending_approval' AND NEW.decision = 'allowed' + AND NEW.reason_code = 'operator_approved') + OR (OLD.decision = 'pending_approval' AND NEW.decision = 'denied' + AND NEW.reason_code = 'operator_denied') + OR (OLD.decision = 'challenged' AND NEW.decision = 'allowed' + AND NEW.reason_code = 'authorization_challenge_authorized') + OR (OLD.decision = 'challenged' AND NEW.decision = 'denied' + AND NEW.reason_code = 'authorization_challenge_expired') + ) +BEGIN + SELECT RAISE(ABORT, 'request decision transition is invalid'); +END"# + }; +} + +macro_rules! nip46_request_decisions_no_delete_sql { + () => { + r#"CREATE TRIGGER nip46_request_decisions_no_delete +BEFORE DELETE ON nip46_request_decisions +BEGIN + SELECT RAISE(ABORT, 'request decision evidence is retained'); +END"# + }; +} + +macro_rules! connection_auth_challenges_guard_update_sql { + () => { + r#"CREATE TRIGGER connection_auth_challenges_guard_update +BEFORE UPDATE ON connection_auth_challenges +WHEN NEW.challenge_id != OLD.challenge_id + OR NEW.challenge_nonce != OLD.challenge_nonce + OR NEW.connection_id != OLD.connection_id + OR NEW.operation_id != OLD.operation_id + OR NEW.policy_generation != OLD.policy_generation + OR NEW.challenge_url != OLD.challenge_url + OR NEW.issued_at_unix_ms != OLD.issued_at_unix_ms + OR NEW.expires_at_unix_ms != OLD.expires_at_unix_ms + OR NOT (OLD.state = 'pending' + AND NEW.state IN ('authorized', 'expired') + AND NEW.resolved_at_unix_ms IS NOT NULL + AND NEW.resolved_at_unix_ms >= OLD.issued_at_unix_ms) +BEGIN + SELECT RAISE(ABORT, 'authorization challenge transition is invalid'); +END"# + }; +} + +macro_rules! connection_auth_challenges_no_delete_sql { + () => { + r#"CREATE TRIGGER connection_auth_challenges_no_delete +BEFORE DELETE ON connection_auth_challenges +BEGIN + SELECT RAISE(ABORT, 'authorization challenge evidence is retained'); +END"# + }; +} + +const CREATE_CONNECTIONS_TABLE_SQL: &str = connections_table_sql!(); +const CREATE_CONNECTION_PERMISSIONS_TABLE_SQL: &str = connection_permissions_table_sql!(); +const CREATE_NIP46_REQUEST_DECISIONS_TABLE_SQL: &str = nip46_request_decisions_table_sql!(); +const CREATE_CONNECTION_AUTH_CHALLENGES_TABLE_SQL: &str = connection_auth_challenges_table_sql!(); +const CREATE_CONNECTIONS_GUARD_UPDATE_SQL: &str = connections_guard_update_sql!(); +const CREATE_CONNECTIONS_NO_DELETE_SQL: &str = connections_no_delete_sql!(); +const CREATE_CONNECTION_PERMISSIONS_NO_UPDATE_SQL: &str = connection_permissions_no_update_sql!(); +const CREATE_CONNECTION_PERMISSIONS_NO_DELETE_SQL: &str = connection_permissions_no_delete_sql!(); +const CREATE_NIP46_REQUEST_DECISIONS_GUARD_UPDATE_SQL: &str = + nip46_request_decisions_guard_update_sql!(); +const CREATE_NIP46_REQUEST_DECISIONS_NO_DELETE_SQL: &str = nip46_request_decisions_no_delete_sql!(); +const CREATE_CONNECTION_AUTH_CHALLENGES_GUARD_UPDATE_SQL: &str = + connection_auth_challenges_guard_update_sql!(); +const CREATE_CONNECTION_AUTH_CHALLENGES_NO_DELETE_SQL: &str = + connection_auth_challenges_no_delete_sql!(); + +const CREATE_CONNECTION_STATE_MIGRATION_SQL: &str = concat!( + "DROP TRIGGER myc_state_metadata_no_update;\n", + "UPDATE myc_state_metadata SET state_contract_version = CASE ", + "WHEN state_contract_version = 3 THEN 4 ELSE 0 END WHERE singleton = 1;\n", + myc_state_metadata_no_update_sql!(), + ";\n", + connections_table_sql!(), + ";\n", + connection_permissions_table_sql!(), + ";\n", + nip46_request_decisions_table_sql!(), + ";\n", + connection_auth_challenges_table_sql!(), + ";\n", + connections_guard_update_sql!(), + ";\n", + connections_no_delete_sql!(), + ";\n", + connection_permissions_no_update_sql!(), + ";\n", + connection_permissions_no_delete_sql!(), + ";\n", + nip46_request_decisions_guard_update_sql!(), + ";\n", + nip46_request_decisions_no_delete_sql!(), + ";\n", + connection_auth_challenges_guard_update_sql!(), + ";\n", + connection_auth_challenges_no_delete_sql!(), +); + +const CONNECTIONS_TABLE_SHA256: [u8; 32] = [ + 0x72, 0xd5, 0xd8, 0xba, 0x24, 0x68, 0x9c, 0x93, 0x34, 0xb3, 0x8f, 0xbf, 0x64, 0x21, 0xe1, 0x65, + 0xfd, 0xc3, 0x80, 0x46, 0xf1, 0x3f, 0x56, 0x49, 0x3a, 0xef, 0xd7, 0x42, 0xc4, 0xe6, 0x49, 0x85, +]; +const CONNECTION_PERMISSIONS_TABLE_SHA256: [u8; 32] = [ + 0xc0, 0x84, 0xe6, 0x03, 0xa3, 0xb8, 0xa3, 0x78, 0xeb, 0x58, 0x00, 0x6f, 0x1c, 0x1c, 0xdd, 0x76, + 0x7f, 0x67, 0xc7, 0xf4, 0xc8, 0x9d, 0x4c, 0x58, 0x02, 0x57, 0x2d, 0xca, 0x1e, 0x7d, 0x83, 0x02, +]; +const NIP46_REQUEST_DECISIONS_TABLE_SHA256: [u8; 32] = [ + 0xe8, 0x53, 0x1d, 0xed, 0xad, 0x22, 0xfd, 0xfe, 0x96, 0xd4, 0x17, 0x04, 0xea, 0x05, 0x6d, 0xf1, + 0x2f, 0xc2, 0x99, 0xac, 0x1a, 0xbf, 0x73, 0xff, 0xcc, 0x6f, 0x2c, 0x5f, 0xdc, 0x27, 0xd9, 0x80, +]; +const CONNECTION_AUTH_CHALLENGES_TABLE_SHA256: [u8; 32] = [ + 0x65, 0x09, 0x11, 0x3c, 0x4b, 0xfa, 0x30, 0x16, 0x7e, 0x0b, 0xc8, 0xf6, 0x67, 0xf5, 0x38, 0xc5, + 0x5a, 0xd9, 0x4e, 0x0e, 0xb7, 0x18, 0x22, 0x94, 0x73, 0xea, 0x11, 0x74, 0x9c, 0x8e, 0xa4, 0x37, +]; +const CONNECTIONS_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0x66, 0x61, 0xb0, 0xc6, 0x78, 0x3a, 0x0d, 0x4a, 0x01, 0xb9, 0x7e, 0x7e, 0xd0, 0x8e, 0x2b, 0x6e, + 0xdb, 0xd5, 0x3a, 0x28, 0x56, 0x11, 0x88, 0x80, 0x16, 0xf3, 0x2e, 0x5a, 0x42, 0xf0, 0xf9, 0xfe, +]; +const CONNECTIONS_NO_DELETE_SHA256: [u8; 32] = [ + 0xb0, 0xcf, 0x50, 0x22, 0x23, 0x2a, 0xab, 0x23, 0x62, 0x1f, 0xfc, 0x54, 0x8c, 0xc5, 0x88, 0xdb, + 0x8c, 0x6e, 0x4a, 0xe0, 0x91, 0x48, 0x0d, 0xda, 0xc8, 0x79, 0x4b, 0x6c, 0x0c, 0x06, 0x3f, 0xaa, +]; +const CONNECTION_PERMISSIONS_NO_UPDATE_SHA256: [u8; 32] = [ + 0x5e, 0x11, 0x4c, 0xa4, 0x28, 0x69, 0x0e, 0xa7, 0x64, 0x3d, 0x67, 0xbc, 0x30, 0x0f, 0x3f, 0xf1, + 0xe9, 0x7e, 0xfe, 0x2f, 0x8d, 0xbd, 0x7b, 0x79, 0x47, 0x18, 0x56, 0x3d, 0xb6, 0x64, 0x70, 0x1f, +]; +const CONNECTION_PERMISSIONS_NO_DELETE_SHA256: [u8; 32] = [ + 0xd0, 0x27, 0x41, 0x8e, 0x03, 0x72, 0x87, 0x09, 0x16, 0x49, 0x1d, 0x83, 0x28, 0x98, 0xb9, 0x47, + 0xe1, 0x1f, 0xf8, 0xe6, 0x57, 0xbd, 0x89, 0x2c, 0x90, 0xa5, 0x5c, 0x30, 0x51, 0xfe, 0x2b, 0xd7, +]; +const NIP46_REQUEST_DECISIONS_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0x1b, 0xf7, 0xe9, 0xb9, 0x52, 0x64, 0x95, 0x6b, 0x43, 0xf2, 0xc8, 0xdd, 0x73, 0x82, 0xc8, 0xbf, + 0x0a, 0xc3, 0xcc, 0x91, 0xa2, 0x95, 0xd7, 0xe2, 0x25, 0xc1, 0xcb, 0xc2, 0xc3, 0xa8, 0x1b, 0x26, +]; +const NIP46_REQUEST_DECISIONS_NO_DELETE_SHA256: [u8; 32] = [ + 0xab, 0xd0, 0x76, 0xca, 0xe9, 0x17, 0x53, 0x6d, 0xdc, 0x9d, 0x03, 0x23, 0x1e, 0xc4, 0xdc, 0xc8, + 0xab, 0x8e, 0x7a, 0xc4, 0x23, 0x4e, 0x64, 0x39, 0xaa, 0x58, 0x8b, 0x54, 0x15, 0x7a, 0x2d, 0x34, +]; +const CONNECTION_AUTH_CHALLENGES_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0xb3, 0x24, 0x1e, 0x29, 0x5b, 0x29, 0x68, 0x9b, 0x73, 0x72, 0x5d, 0xe2, 0xce, 0x7c, 0x8c, 0x2b, + 0x85, 0xd0, 0xd2, 0xe1, 0xd8, 0xd0, 0x24, 0x6d, 0x35, 0x68, 0x23, 0x2b, 0x9e, 0x87, 0xe4, 0xa8, +]; +const CONNECTION_AUTH_CHALLENGES_NO_DELETE_SHA256: [u8; 32] = [ + 0xf3, 0xf8, 0xd1, 0x48, 0xbc, 0xde, 0x89, 0xd3, 0x34, 0xcd, 0xde, 0x51, 0x4b, 0x83, 0xa2, 0x19, + 0x16, 0xe5, 0xd6, 0x72, 0xb7, 0xc3, 0x1e, 0x59, 0xcb, 0xdf, 0x3a, 0x3c, 0x33, 0x80, 0x21, 0x4f, +]; + /// Stable classes for invalid embedded Myc catalog definitions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MycStateCatalogErrorKind { @@ -354,10 +701,17 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; - let catalog = MigrationCatalog::new([metadata, requests]) + let connections = MigrationDescriptor::sql( + 4, + "create_connection_authorization_state", + CREATE_CONNECTION_STATE_MIGRATION_SQL, + MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_4_MIGRATION_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; + let catalog = MigrationCatalog::new([metadata, requests, connections]) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; if catalog.current_version() != MYC_STATE_SCHEMA_VERSION - || catalog.descriptors().len() != 2 + || catalog.descriptors().len() != 3 || catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256 { return Err(MycStateCatalogError::new( @@ -388,8 +742,17 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_3_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; - let catalog = SchemaCatalog::new(&migrations, [version_one, version_two, version_three]) - .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + let version_four = SchemaVersionCatalog::new( + 4, + myc_state_connection_objects()?, + SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_4_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + let catalog = SchemaCatalog::new( + &migrations, + [version_one, version_two, version_three, version_four], + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; validate_myc_state_catalogs(&migrations, &catalog)?; Ok(catalog) } @@ -463,6 +826,115 @@ fn myc_state_request_objects() -> Result<[SchemaObject; 7], MycStateCatalogError ]) } +fn myc_state_connection_objects() -> Result<[SchemaObject; 19], MycStateCatalogError> { + let [ + metadata_table, + metadata_update, + metadata_delete, + request_table, + request_dedup, + request_update, + request_dedup_update, + ] = myc_state_request_objects()?; + let object = |kind, name, table, sql, digest| { + SchemaObject::new(kind, name, table, sql, SchemaDigest::from_bytes(digest)) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog)) + }; + Ok([ + metadata_table, + metadata_update, + metadata_delete, + request_table, + request_dedup, + request_update, + request_dedup_update, + object( + SchemaObjectKind::Table, + "connections", + "connections", + CREATE_CONNECTIONS_TABLE_SQL, + CONNECTIONS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "connection_permissions", + "connection_permissions", + CREATE_CONNECTION_PERMISSIONS_TABLE_SQL, + CONNECTION_PERMISSIONS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "nip46_request_decisions", + "nip46_request_decisions", + CREATE_NIP46_REQUEST_DECISIONS_TABLE_SQL, + NIP46_REQUEST_DECISIONS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "connection_auth_challenges", + "connection_auth_challenges", + CREATE_CONNECTION_AUTH_CHALLENGES_TABLE_SQL, + CONNECTION_AUTH_CHALLENGES_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "connections_guard_update", + "connections", + CREATE_CONNECTIONS_GUARD_UPDATE_SQL, + CONNECTIONS_GUARD_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "connections_no_delete", + "connections", + CREATE_CONNECTIONS_NO_DELETE_SQL, + CONNECTIONS_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "connection_permissions_no_update", + "connection_permissions", + CREATE_CONNECTION_PERMISSIONS_NO_UPDATE_SQL, + CONNECTION_PERMISSIONS_NO_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "connection_permissions_no_delete", + "connection_permissions", + CREATE_CONNECTION_PERMISSIONS_NO_DELETE_SQL, + CONNECTION_PERMISSIONS_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "nip46_request_decisions_guard_update", + "nip46_request_decisions", + CREATE_NIP46_REQUEST_DECISIONS_GUARD_UPDATE_SQL, + NIP46_REQUEST_DECISIONS_GUARD_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "nip46_request_decisions_no_delete", + "nip46_request_decisions", + CREATE_NIP46_REQUEST_DECISIONS_NO_DELETE_SQL, + NIP46_REQUEST_DECISIONS_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "connection_auth_challenges_guard_update", + "connection_auth_challenges", + CREATE_CONNECTION_AUTH_CHALLENGES_GUARD_UPDATE_SQL, + CONNECTION_AUTH_CHALLENGES_GUARD_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "connection_auth_challenges_no_delete", + "connection_auth_challenges", + CREATE_CONNECTION_AUTH_CHALLENGES_NO_DELETE_SQL, + CONNECTION_AUTH_CHALLENGES_NO_DELETE_SHA256, + )?, + ]) +} + /// Independently validates exact catalog versions, counts, and digests. pub fn validate_myc_state_catalogs( migrations: &MigrationCatalog, @@ -471,16 +943,19 @@ pub fn validate_myc_state_catalogs( let versions = schema.versions(); let descriptors = migrations.descriptors(); let valid = migrations.current_version() == MYC_STATE_SCHEMA_VERSION - && descriptors.len() == 2 + && descriptors.len() == 3 && descriptors[0].target_version() == 2 && descriptors[0].name().as_str() == "create_myc_state_metadata" && descriptors[0].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256 && descriptors[1].target_version() == 3 && descriptors[1].name().as_str() == "create_nip46_request_admission" && descriptors[1].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256 + && descriptors[2].target_version() == 4 + && descriptors[2].name().as_str() == "create_connection_authorization_state" + && descriptors[2].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_4_MIGRATION_SHA256 && migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 3 + && versions.len() == 4 && versions[0].version() == MYC_STATE_BASE_SCHEMA_VERSION && versions[0].object_count() == MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT && versions[0].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_1_SHA256 @@ -490,6 +965,9 @@ pub fn validate_myc_state_catalogs( && versions[2].version() == 3 && versions[2].object_count() == MYC_STATE_SCHEMA_VERSION_3_OBJECT_COUNT && versions[2].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_3_SHA256 + && versions[3].version() == 4 + && versions[3].object_count() == MYC_STATE_SCHEMA_VERSION_4_OBJECT_COUNT + && versions[3].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_4_SHA256 && schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256; if valid { Ok(()) diff --git a/src/state_connection.rs b/src/state_connection.rs @@ -0,0 +1,2021 @@ +//! Durable typed Myc connection, approval, and authorization-challenge state. + +use core::fmt; +use std::error::Error; + +use radroots_service_sqlite::{ + ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, +}; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use url::{Host, Url}; + +use crate::state_repository::{ + MycStateRepository, MycStateRepositoryError, MycStateRepositoryErrorKind, PersistedMetadata, + RepositoryOperationError, require_expected_metadata, +}; +use crate::{MycNip46ClientPublicKey, MycSignerOperationId, MycSignerRequestMethod}; + +/// Maximum number of independently granted permissions on one connection. +pub const MYC_CONNECTION_PERMISSION_MAX_COUNT: usize = 64; +/// Maximum canonical byte length of an operator-owned challenge URL. +pub const MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES: usize = 2_048; + +const CONNECTION_ID_DOMAIN: &[u8] = b"radroots.myc.connection.v1\0"; +const CHALLENGE_ID_DOMAIN: &[u8] = b"radroots.myc.authorization_challenge.v1\0"; +const PERMISSION_SET_DOMAIN: &[u8] = b"radroots.myc.connection_permissions.v1\0"; + +const READ_REQUEST_BINDING_SQL: &str = r#"SELECT + CASE WHEN typeof(client_public_key) = 'text' + AND length(CAST(client_public_key AS BLOB)) = 64 + THEN client_public_key ELSE NULL END AS client_public_key, + CASE WHEN typeof(method) = 'text' + AND length(CAST(method AS BLOB)) BETWEEN 1 AND 32 + THEN method ELSE NULL END AS method, + received_at_unix_ms +FROM nip46_requests +WHERE operation_id = ? +LIMIT 2"#; + +const READ_DECISION_SQL: &str = r#"SELECT + CASE WHEN typeof(connection_id) = 'blob' AND length(connection_id) = 32 + THEN connection_id ELSE NULL END AS connection_id, + typeof(connection_id) AS connection_id_type, + CASE WHEN typeof(decision) = 'text' AND length(CAST(decision AS BLOB)) <= 32 + THEN decision ELSE NULL END AS decision, + CASE WHEN typeof(reason_code) = 'text' AND length(CAST(reason_code AS BLOB)) <= 40 + THEN reason_code ELSE NULL END AS reason_code, + policy_generation, + CASE WHEN typeof(requested_permissions_sha256) = 'blob' + AND length(requested_permissions_sha256) = 32 + THEN requested_permissions_sha256 ELSE NULL END AS requested_permissions_sha256, + CASE WHEN typeof(challenge_id) = 'blob' AND length(challenge_id) = 32 + THEN challenge_id ELSE NULL END AS challenge_id, + typeof(challenge_id) AS challenge_id_type, + decided_at_unix_ms +FROM nip46_request_decisions +WHERE operation_id = ? +LIMIT 2"#; + +const INSERT_DECISION_SQL: &str = r#"INSERT INTO nip46_request_decisions ( + operation_id, connection_id, decision, reason_code, policy_generation, + requested_permissions_sha256, challenge_id, decided_at_unix_ms +) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"#; + +const INSERT_CONNECTION_SQL: &str = r#"INSERT INTO connections ( + connection_id, connection_nonce, client_public_key, requested_permissions_sha256, + policy_generation, status, created_at_unix_ms, updated_at_unix_ms, + authorized_until_unix_ms +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"#; + +const INSERT_PERMISSION_SQL: &str = r#"INSERT INTO connection_permissions ( + connection_id, permission_scope, permission_code +) VALUES (?, ?, ?)"#; + +const READ_CONNECTION_SQL: &str = r#"SELECT + CASE WHEN typeof(connection_id) = 'blob' AND length(connection_id) = 32 + THEN connection_id ELSE NULL END AS connection_id, + CASE WHEN typeof(connection_nonce) = 'blob' AND length(connection_nonce) = 32 + THEN connection_nonce ELSE NULL END AS connection_nonce, + CASE WHEN typeof(client_public_key) = 'text' + AND length(CAST(client_public_key AS BLOB)) = 64 + THEN client_public_key ELSE NULL END AS client_public_key, + CASE WHEN typeof(requested_permissions_sha256) = 'blob' + AND length(requested_permissions_sha256) = 32 + THEN requested_permissions_sha256 ELSE NULL END AS requested_permissions_sha256, + policy_generation, + CASE WHEN typeof(status) = 'text' AND length(CAST(status AS BLOB)) <= 16 + THEN status ELSE NULL END AS status, + created_at_unix_ms, + updated_at_unix_ms, + authorized_until_unix_ms, + typeof(authorized_until_unix_ms) AS authorized_until_type +FROM connections +WHERE connection_id = ? +LIMIT 2"#; + +const READ_PERMISSIONS_SQL: &str = r#"SELECT + CASE WHEN typeof(permission_code) = 'text' + AND length(CAST(permission_code AS BLOB)) BETWEEN 1 AND 64 + THEN permission_code ELSE NULL END AS permission_code +FROM connection_permissions +WHERE connection_id = ? AND permission_scope = ? +LIMIT 65"#; + +const APPROVE_CONNECTION_SQL: &str = r#"UPDATE connections +SET status = 'active', updated_at_unix_ms = ?, authorized_until_unix_ms = ? +WHERE connection_id = ? AND status = 'pending' AND policy_generation = ?"#; + +const DENY_CONNECTION_SQL: &str = r#"UPDATE connections +SET status = 'denied', updated_at_unix_ms = ?, authorized_until_unix_ms = NULL +WHERE connection_id = ? AND status = 'pending' AND policy_generation = ?"#; + +const EXPIRE_CONNECTION_SQL: &str = r#"UPDATE connections +SET status = 'expired', updated_at_unix_ms = ?, authorized_until_unix_ms = NULL +WHERE connection_id = ? AND status = 'active' AND policy_generation = ? + AND authorized_until_unix_ms IS NOT NULL AND authorized_until_unix_ms < ?"#; + +const UPDATE_APPROVAL_DECISION_SQL: &str = r#"UPDATE nip46_request_decisions +SET decision = ?, reason_code = ?, decided_at_unix_ms = ? +WHERE operation_id = ? AND connection_id = ? AND decision = 'pending_approval' + AND policy_generation = ?"#; + +const INSERT_CHALLENGE_SQL: &str = r#"INSERT INTO connection_auth_challenges ( + challenge_id, challenge_nonce, connection_id, operation_id, policy_generation, + challenge_url, state, issued_at_unix_ms, expires_at_unix_ms, resolved_at_unix_ms +) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, NULL)"#; + +const READ_CHALLENGE_SQL: &str = r#"SELECT + CASE WHEN typeof(challenge_id) = 'blob' AND length(challenge_id) = 32 + THEN challenge_id ELSE NULL END AS challenge_id, + CASE WHEN typeof(challenge_nonce) = 'blob' AND length(challenge_nonce) = 32 + THEN challenge_nonce ELSE NULL END AS challenge_nonce, + CASE WHEN typeof(connection_id) = 'blob' AND length(connection_id) = 32 + THEN connection_id ELSE NULL END AS connection_id, + CASE WHEN typeof(operation_id) = 'blob' AND length(operation_id) = 32 + THEN operation_id ELSE NULL END AS operation_id, + policy_generation, + CASE WHEN typeof(challenge_url) = 'text' + AND length(CAST(challenge_url AS BLOB)) BETWEEN 1 AND 2048 + THEN challenge_url ELSE NULL END AS challenge_url, + CASE WHEN typeof(state) = 'text' AND length(CAST(state AS BLOB)) <= 16 + THEN state ELSE NULL END AS state, + issued_at_unix_ms, expires_at_unix_ms, resolved_at_unix_ms, + typeof(resolved_at_unix_ms) AS resolved_at_type +FROM connection_auth_challenges +WHERE operation_id = ? +LIMIT 2"#; + +const RESOLVE_CHALLENGE_SQL: &str = r#"UPDATE connection_auth_challenges +SET state = ?, resolved_at_unix_ms = ? +WHERE challenge_id = ? AND connection_id = ? AND operation_id = ? + AND policy_generation = ? AND state = 'pending'"#; + +const UPDATE_CHALLENGE_DECISION_SQL: &str = r#"UPDATE nip46_request_decisions +SET decision = ?, reason_code = ?, decided_at_unix_ms = ? +WHERE operation_id = ? AND connection_id = ? AND challenge_id = ? + AND policy_generation = ? AND decision = 'challenged'"#; + +/// Stable construction and validation failure classes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycConnectionStateErrorKind { + InvalidPermissionSet, + InvalidPolicyGeneration, + InvalidTime, + InvalidChallengeUrl, + InvalidChallengeLifetime, +} + +impl MycConnectionStateErrorKind { + /// Returns the stable machine-readable error code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidPermissionSet => "connection_permission_set_invalid", + Self::InvalidPolicyGeneration => "connection_policy_generation_invalid", + Self::InvalidTime => "connection_time_invalid", + Self::InvalidChallengeUrl => "authorization_challenge_url_invalid", + Self::InvalidChallengeLifetime => "authorization_challenge_lifetime_invalid", + } + } +} + +/// Source-free connection-state validation failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycConnectionStateError { + kind: MycConnectionStateErrorKind, +} + +impl MycConnectionStateError { + const fn new(kind: MycConnectionStateErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> MycConnectionStateErrorKind { + self.kind + } + + /// Returns the stable machine-readable error code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for MycConnectionStateError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycConnectionStateErrorKind::InvalidPermissionSet => { + "connection permission set is invalid" + } + MycConnectionStateErrorKind::InvalidPolicyGeneration => { + "connection policy generation is invalid" + } + MycConnectionStateErrorKind::InvalidTime => "connection time is invalid", + MycConnectionStateErrorKind::InvalidChallengeUrl => { + "authorization challenge URL is invalid" + } + MycConnectionStateErrorKind::InvalidChallengeLifetime => { + "authorization challenge lifetime is invalid" + } + }) + } +} + +impl fmt::Debug for MycConnectionStateError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycConnectionStateError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycConnectionStateError {} + +/// One closed Myc connection permission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum MycConnectionPermission { + GetPublicKey, + GetSessionCapability, + SignEvent(u16), + Nip04Encrypt, + Nip04Decrypt, + Nip44Encrypt, + Nip44Decrypt, + Ping, + SwitchRelays, + Logout, +} + +impl MycConnectionPermission { + fn code(self) -> String { + match self { + Self::GetPublicKey => "get_public_key".into(), + Self::GetSessionCapability => "get_session_capability".into(), + Self::SignEvent(kind) => format!("sign_event:kind:{kind}"), + Self::Nip04Encrypt => "nip04_encrypt".into(), + Self::Nip04Decrypt => "nip04_decrypt".into(), + Self::Nip44Encrypt => "nip44_encrypt".into(), + Self::Nip44Decrypt => "nip44_decrypt".into(), + Self::Ping => "ping".into(), + Self::SwitchRelays => "switch_relays".into(), + Self::Logout => "logout".into(), + } + } + + fn parse(value: &str) -> Option<Self> { + match value { + "get_public_key" => Some(Self::GetPublicKey), + "get_session_capability" => Some(Self::GetSessionCapability), + "nip04_encrypt" => Some(Self::Nip04Encrypt), + "nip04_decrypt" => Some(Self::Nip04Decrypt), + "nip44_encrypt" => Some(Self::Nip44Encrypt), + "nip44_decrypt" => Some(Self::Nip44Decrypt), + "ping" => Some(Self::Ping), + "switch_relays" => Some(Self::SwitchRelays), + "logout" => Some(Self::Logout), + _ => value + .strip_prefix("sign_event:kind:") + .and_then(|kind| kind.parse::<u16>().ok().map(|parsed| (kind, parsed))) + .filter(|(kind, parsed)| *kind == parsed.to_string()) + .map(|(_, parsed)| Self::SignEvent(parsed)), + } + } +} + +/// Immutable canonical set of connection permissions. +#[derive(Clone, PartialEq, Eq)] +pub struct MycConnectionPermissionSet { + permissions: Box<[MycConnectionPermission]>, + digest: [u8; 32], +} + +impl MycConnectionPermissionSet { + /// Validates, orders, and seals one bounded permission set. + pub fn new(permissions: &[MycConnectionPermission]) -> Result<Self, MycConnectionStateError> { + if permissions.len() > MYC_CONNECTION_PERMISSION_MAX_COUNT { + return Err(MycConnectionStateError::new( + MycConnectionStateErrorKind::InvalidPermissionSet, + )); + } + let mut normalized = permissions.to_vec(); + normalized.sort_unstable(); + if normalized.windows(2).any(|window| window[0] == window[1]) { + return Err(MycConnectionStateError::new( + MycConnectionStateErrorKind::InvalidPermissionSet, + )); + } + let digest = permission_digest(&normalized); + Ok(Self { + permissions: normalized.into_boxed_slice(), + digest, + }) + } + + /// Returns the canonical ordered permissions. + #[must_use] + pub fn permissions(&self) -> &[MycConnectionPermission] { + &self.permissions + } + + fn digest(&self) -> &[u8; 32] { + &self.digest + } + + fn is_subset_of(&self, other: &Self) -> bool { + self.permissions + .iter() + .all(|permission| other.permissions.binary_search(permission).is_ok()) + } +} + +impl fmt::Debug for MycConnectionPermissionSet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycConnectionPermissionSet") + .field("permission_count", &self.permissions.len()) + .finish() + } +} + +/// Injected entropy used once to derive a durable connection identity. +pub struct MycConnectionNonce([u8; 32]); + +impl MycConnectionNonce { + /// Wraps caller-injected entropy without reading ambient state. + #[must_use] + pub const fn from_injected_entropy(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl fmt::Debug for MycConnectionNonce { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycConnectionNonce([redacted])") + } +} + +/// Injected entropy used once to derive a durable challenge identity. +pub struct MycAuthorizationChallengeNonce([u8; 32]); + +impl MycAuthorizationChallengeNonce { + /// Wraps caller-injected entropy without reading ambient state. + #[must_use] + pub const fn from_injected_entropy(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl fmt::Debug for MycAuthorizationChallengeNonce { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycAuthorizationChallengeNonce([redacted])") + } +} + +macro_rules! digest_id { + ($name:ident, $debug:literal) => { + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name([u8; 32]); + + impl $name { + /// Returns the exact stable identity bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str($debug) + } + } + }; +} + +digest_id!(MycConnectionId, "MycConnectionId([redacted])"); +digest_id!( + MycAuthorizationChallengeId, + "MycAuthorizationChallengeId([redacted])" +); + +/// Nonzero immutable connection-policy generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct MycConnectionPolicyGeneration(u64); + +impl MycConnectionPolicyGeneration { + /// Constructs a policy generation representable by SQLite. + pub fn new(value: u64) -> Result<Self, MycConnectionStateError> { + if value == 0 || i64::try_from(value).is_err() { + return Err(MycConnectionStateError::new( + MycConnectionStateErrorKind::InvalidPolicyGeneration, + )); + } + Ok(Self(value)) + } + + /// Returns the generation value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + fn sqlite_value(self) -> i64 { + i64::try_from(self.0).expect("validated generation fits SQLite") + } +} + +/// Nonzero immutable millisecond UTC evidence supplied by the caller. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct MycConnectionTimeUnixMs(u64); + +impl MycConnectionTimeUnixMs { + /// Constructs a timestamp representable by SQLite. + pub fn new(value: u64) -> Result<Self, MycConnectionStateError> { + if value == 0 || i64::try_from(value).is_err() { + return Err(MycConnectionStateError::new( + MycConnectionStateErrorKind::InvalidTime, + )); + } + Ok(Self(value)) + } + + /// Returns the injected timestamp. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + fn sqlite_value(self) -> i64 { + i64::try_from(self.0).expect("validated time fits SQLite") + } +} + +/// Closed admission authority for a connect request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycConnectionAdmissionPolicy { + Trusted, + ExplicitApproval, + Denied, +} + +impl MycConnectionAdmissionPolicy { + const fn decision(self) -> MycConnectionDecision { + match self { + Self::Trusted => MycConnectionDecision::Allowed, + Self::ExplicitApproval => MycConnectionDecision::PendingApproval, + Self::Denied => MycConnectionDecision::Denied, + } + } +} + +/// Stable durable request decision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycConnectionDecision { + PendingApproval, + Challenged, + Allowed, + Denied, +} + +impl MycConnectionDecision { + const fn as_str(self) -> &'static str { + match self { + Self::PendingApproval => "pending_approval", + Self::Challenged => "challenged", + Self::Allowed => "allowed", + Self::Denied => "denied", + } + } + + const fn reason(self, policy: MycConnectionAdmissionPolicy) -> &'static str { + match (self, policy) { + (Self::PendingApproval, _) => "explicit_approval_required", + (Self::Allowed, _) => "trusted_client", + (Self::Denied, _) => "policy_denied", + (Self::Challenged, _) => "authorization_challenge_required", + } + } + + fn parse(value: &str) -> Option<Self> { + match value { + "pending_approval" => Some(Self::PendingApproval), + "challenged" => Some(Self::Challenged), + "allowed" => Some(Self::Allowed), + "denied" => Some(Self::Denied), + _ => None, + } + } +} + +/// Immutable connect-admission input. +pub struct MycConnectionAdmissionRequest { + operation_id: MycSignerOperationId, + client_public_key: MycNip46ClientPublicKey, + requested_permissions: MycConnectionPermissionSet, + policy_generation: MycConnectionPolicyGeneration, + nonce: MycConnectionNonce, + observed_at: MycConnectionTimeUnixMs, + authorized_until: Option<MycConnectionTimeUnixMs>, + policy: MycConnectionAdmissionPolicy, +} + +impl MycConnectionAdmissionRequest { + /// Constructs a request from validated protocol, policy, entropy, and time evidence. + #[allow(clippy::too_many_arguments)] + pub fn new( + operation_id: MycSignerOperationId, + client_public_key: MycNip46ClientPublicKey, + requested_permissions: MycConnectionPermissionSet, + policy_generation: MycConnectionPolicyGeneration, + nonce: MycConnectionNonce, + observed_at: MycConnectionTimeUnixMs, + authorized_until: Option<MycConnectionTimeUnixMs>, + policy: MycConnectionAdmissionPolicy, + ) -> Result<Self, MycConnectionStateError> { + if (matches!(policy, MycConnectionAdmissionPolicy::Trusted) + && authorized_until.is_some_and(|until| until <= observed_at)) + || (!matches!(policy, MycConnectionAdmissionPolicy::Trusted) + && authorized_until.is_some()) + { + return Err(MycConnectionStateError::new( + MycConnectionStateErrorKind::InvalidTime, + )); + } + Ok(Self { + operation_id, + client_public_key, + requested_permissions, + policy_generation, + nonce, + observed_at, + authorized_until, + policy, + }) + } + + fn owned(&self) -> Self { + Self { + operation_id: self.operation_id, + client_public_key: self.client_public_key.clone(), + requested_permissions: self.requested_permissions.clone(), + policy_generation: self.policy_generation, + nonce: MycConnectionNonce(self.nonce.0), + observed_at: self.observed_at, + authorized_until: self.authorized_until, + policy: self.policy, + } + } +} + +impl fmt::Debug for MycConnectionAdmissionRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycConnectionAdmissionRequest([redacted])") + } +} + +/// Durable connection status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycConnectionStatus { + Pending, + Active, + Denied, + Expired, +} + +impl MycConnectionStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Active => "active", + Self::Denied => "denied", + Self::Expired => "expired", + } + } + + fn parse(value: &str) -> Option<Self> { + match value { + "pending" => Some(Self::Pending), + "active" => Some(Self::Active), + "denied" => Some(Self::Denied), + "expired" => Some(Self::Expired), + _ => None, + } + } +} + +/// Validated durable connection record. +#[derive(Clone, PartialEq, Eq)] +pub struct MycConnectionRecord { + id: MycConnectionId, + client_public_key: MycNip46ClientPublicKey, + requested_permissions: MycConnectionPermissionSet, + granted_permissions: MycConnectionPermissionSet, + policy_generation: MycConnectionPolicyGeneration, + status: MycConnectionStatus, + created_at: MycConnectionTimeUnixMs, + updated_at: MycConnectionTimeUnixMs, + authorized_until: Option<MycConnectionTimeUnixMs>, +} + +impl MycConnectionRecord { + #[must_use] + pub const fn id(&self) -> MycConnectionId { + self.id + } + #[must_use] + pub const fn client_public_key(&self) -> &MycNip46ClientPublicKey { + &self.client_public_key + } + #[must_use] + pub const fn requested_permissions(&self) -> &MycConnectionPermissionSet { + &self.requested_permissions + } + #[must_use] + pub const fn granted_permissions(&self) -> &MycConnectionPermissionSet { + &self.granted_permissions + } + #[must_use] + pub const fn policy_generation(&self) -> MycConnectionPolicyGeneration { + self.policy_generation + } + #[must_use] + pub const fn status(&self) -> MycConnectionStatus { + self.status + } + #[must_use] + pub const fn created_at(&self) -> MycConnectionTimeUnixMs { + self.created_at + } + #[must_use] + pub const fn updated_at(&self) -> MycConnectionTimeUnixMs { + self.updated_at + } + #[must_use] + pub const fn authorized_until(&self) -> Option<MycConnectionTimeUnixMs> { + self.authorized_until + } +} + +impl fmt::Debug for MycConnectionRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycConnectionRecord") + .field("status", &self.status) + .field( + "permission_count", + &self.granted_permissions.permissions.len(), + ) + .finish() + } +} + +/// Durable result for initial connection admission. +#[derive(Clone, PartialEq, Eq)] +pub struct MycConnectionDecisionRecord { + operation_id: MycSignerOperationId, + connection: Option<MycConnectionRecord>, + decision: MycConnectionDecision, + policy_generation: MycConnectionPolicyGeneration, + decided_at: MycConnectionTimeUnixMs, +} + +impl MycConnectionDecisionRecord { + #[must_use] + pub const fn operation_id(&self) -> MycSignerOperationId { + self.operation_id + } + #[must_use] + pub const fn connection(&self) -> Option<&MycConnectionRecord> { + self.connection.as_ref() + } + #[must_use] + pub const fn decision(&self) -> MycConnectionDecision { + self.decision + } + #[must_use] + pub const fn policy_generation(&self) -> MycConnectionPolicyGeneration { + self.policy_generation + } + #[must_use] + pub const fn decided_at(&self) -> MycConnectionTimeUnixMs { + self.decided_at + } +} + +impl fmt::Debug for MycConnectionDecisionRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycConnectionDecisionRecord") + .field("decision", &self.decision) + .finish() + } +} + +/// New or replayed connection-admission result. +#[derive(Clone, PartialEq, Eq)] +pub enum MycConnectionAdmission { + Admitted(MycConnectionDecisionRecord), + ExactReplay(MycConnectionDecisionRecord), +} + +impl MycConnectionAdmission { + #[must_use] + pub const fn record(&self) -> &MycConnectionDecisionRecord { + match self { + Self::Admitted(record) | Self::ExactReplay(record) => record, + } + } +} + +impl fmt::Debug for MycConnectionAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Admitted(_) => "MycConnectionAdmission::Admitted([redacted])", + Self::ExactReplay(_) => "MycConnectionAdmission::ExactReplay([redacted])", + }) + } +} + +/// Operator decision for a pending explicit-approval connection. +pub enum MycConnectionOperatorDecision { + Approve { + granted_permissions: MycConnectionPermissionSet, + authorized_until: Option<MycConnectionTimeUnixMs>, + }, + Deny, +} + +impl fmt::Debug for MycConnectionOperatorDecision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Approve { .. } => "MycConnectionOperatorDecision::Approve([redacted])", + Self::Deny => "MycConnectionOperatorDecision::Deny", + }) + } +} + +/// Validated operator-owned challenge URL. +#[derive(Clone, PartialEq, Eq)] +pub struct MycAuthorizationChallengeUrl(Box<str>); + +impl MycAuthorizationChallengeUrl { + /// Admits canonical HTTPS URLs and loopback-only HTTP URLs. + pub fn new(value: &str) -> Result<Self, MycConnectionStateError> { + if value.is_empty() || value.len() > MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES { + return Err(MycConnectionStateError::new( + MycConnectionStateErrorKind::InvalidChallengeUrl, + )); + } + let parsed = Url::parse(value).map_err(|_| { + MycConnectionStateError::new(MycConnectionStateErrorKind::InvalidChallengeUrl) + })?; + let loopback_http = parsed.scheme() == "http" + && match parsed.host() { + Some(Host::Domain("localhost")) => true, + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + _ => false, + }; + if (parsed.scheme() != "https" && !loopback_http) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + || parsed.as_str() != value + { + return Err(MycConnectionStateError::new( + MycConnectionStateErrorKind::InvalidChallengeUrl, + )); + } + Ok(Self(value.into())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for MycAuthorizationChallengeUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycAuthorizationChallengeUrl([redacted])") + } +} + +/// Immutable authorization-challenge creation input. +pub struct MycAuthorizationChallengeRequest { + operation_id: MycSignerOperationId, + connection_id: MycConnectionId, + policy_generation: MycConnectionPolicyGeneration, + url: MycAuthorizationChallengeUrl, + nonce: MycAuthorizationChallengeNonce, + issued_at: MycConnectionTimeUnixMs, + expires_at: MycConnectionTimeUnixMs, +} + +impl MycAuthorizationChallengeRequest { + #[allow(clippy::too_many_arguments)] + pub fn new( + operation_id: MycSignerOperationId, + connection_id: MycConnectionId, + policy_generation: MycConnectionPolicyGeneration, + url: MycAuthorizationChallengeUrl, + nonce: MycAuthorizationChallengeNonce, + issued_at: MycConnectionTimeUnixMs, + expires_at: MycConnectionTimeUnixMs, + ) -> Result<Self, MycConnectionStateError> { + if expires_at <= issued_at { + return Err(MycConnectionStateError::new( + MycConnectionStateErrorKind::InvalidChallengeLifetime, + )); + } + Ok(Self { + operation_id, + connection_id, + policy_generation, + url, + nonce, + issued_at, + expires_at, + }) + } + + fn owned(&self) -> Self { + Self { + operation_id: self.operation_id, + connection_id: self.connection_id, + policy_generation: self.policy_generation, + url: self.url.clone(), + nonce: MycAuthorizationChallengeNonce(self.nonce.0), + issued_at: self.issued_at, + expires_at: self.expires_at, + } + } +} + +impl fmt::Debug for MycAuthorizationChallengeRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycAuthorizationChallengeRequest([redacted])") + } +} + +/// Durable authorization-challenge state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycAuthorizationChallengeState { + Pending, + Authorized, + Expired, +} + +impl MycAuthorizationChallengeState { + const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Authorized => "authorized", + Self::Expired => "expired", + } + } + + fn parse(value: &str) -> Option<Self> { + match value { + "pending" => Some(Self::Pending), + "authorized" => Some(Self::Authorized), + "expired" => Some(Self::Expired), + _ => None, + } + } +} + +/// Validated durable challenge record. +#[derive(Clone, PartialEq, Eq)] +pub struct MycAuthorizationChallengeRecord { + id: MycAuthorizationChallengeId, + operation_id: MycSignerOperationId, + connection_id: MycConnectionId, + policy_generation: MycConnectionPolicyGeneration, + url: MycAuthorizationChallengeUrl, + state: MycAuthorizationChallengeState, + issued_at: MycConnectionTimeUnixMs, + expires_at: MycConnectionTimeUnixMs, + resolved_at: Option<MycConnectionTimeUnixMs>, +} + +impl MycAuthorizationChallengeRecord { + #[must_use] + pub const fn id(&self) -> MycAuthorizationChallengeId { + self.id + } + #[must_use] + pub const fn operation_id(&self) -> MycSignerOperationId { + self.operation_id + } + #[must_use] + pub const fn connection_id(&self) -> MycConnectionId { + self.connection_id + } + #[must_use] + pub const fn policy_generation(&self) -> MycConnectionPolicyGeneration { + self.policy_generation + } + #[must_use] + pub const fn url(&self) -> &MycAuthorizationChallengeUrl { + &self.url + } + #[must_use] + pub const fn state(&self) -> MycAuthorizationChallengeState { + self.state + } + #[must_use] + pub const fn issued_at(&self) -> MycConnectionTimeUnixMs { + self.issued_at + } + #[must_use] + pub const fn expires_at(&self) -> MycConnectionTimeUnixMs { + self.expires_at + } + #[must_use] + pub const fn resolved_at(&self) -> Option<MycConnectionTimeUnixMs> { + self.resolved_at + } +} + +impl fmt::Debug for MycAuthorizationChallengeRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycAuthorizationChallengeRecord") + .field("state", &self.state) + .finish() + } +} + +/// New or replayed challenge creation result. +#[derive(Clone, PartialEq, Eq)] +pub enum MycAuthorizationChallengeAdmission { + Created(MycAuthorizationChallengeRecord), + ExactReplay(MycAuthorizationChallengeRecord), +} + +impl MycAuthorizationChallengeAdmission { + #[must_use] + pub const fn record(&self) -> &MycAuthorizationChallengeRecord { + match self { + Self::Created(record) | Self::ExactReplay(record) => record, + } + } +} + +impl fmt::Debug for MycAuthorizationChallengeAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Created(_) => "MycAuthorizationChallengeAdmission::Created([redacted])", + Self::ExactReplay(_) => "MycAuthorizationChallengeAdmission::ExactReplay([redacted])", + }) + } +} + +impl MycStateRepository<'_> { + /// Atomically records trusted, explicit-approval, or direct-denial admission. + pub async fn admit_connection( + &self, + request: &MycConnectionAdmissionRequest, + ) -> Result<MycConnectionAdmission, MycStateRepositoryError> { + let request = request.owned(); + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + verify_metadata(transaction, &expected).await?; + admit_connection(transaction, &request).await + }) + }) + .await + .map_err(map_transaction_error) + } + + /// Atomically approves or denies one pending connection. + pub async fn decide_pending_connection( + &self, + operation_id: MycSignerOperationId, + connection_id: MycConnectionId, + policy_generation: MycConnectionPolicyGeneration, + observed_at: MycConnectionTimeUnixMs, + decision: MycConnectionOperatorDecision, + ) -> Result<MycConnectionRecord, MycStateRepositoryError> { + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + verify_metadata(transaction, &expected).await?; + decide_connection( + transaction, + operation_id, + connection_id, + policy_generation, + observed_at, + decision, + ) + .await + }) + }) + .await + .map_err(map_transaction_error) + } + + /// Idempotently marks an expired authorized connection. + pub async fn expire_connection( + &self, + connection_id: MycConnectionId, + policy_generation: MycConnectionPolicyGeneration, + observed_at: MycConnectionTimeUnixMs, + ) -> Result<MycConnectionRecord, MycStateRepositoryError> { + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + verify_metadata(transaction, &expected).await?; + let before = read_connection(transaction, connection_id).await?; + if before.policy_generation != policy_generation { + return Err(ConnectionOperationError::Binding); + } + if before.status == MycConnectionStatus::Expired { + return Ok(before); + } + if before.status != MycConnectionStatus::Active + || before + .authorized_until + .is_none_or(|until| until >= observed_at) + { + return Err(ConnectionOperationError::Binding); + } + let result = sqlx::query(EXPIRE_CONNECTION_SQL) + .bind(observed_at.sqlite_value()) + .bind(connection_id.as_bytes().as_slice()) + .bind(policy_generation.sqlite_value()) + .bind(observed_at.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected())?; + read_connection(transaction, connection_id).await + }) + }) + .await + .map_err(map_transaction_error) + } + + /// Creates or replays one request-bound authorization challenge. + pub async fn issue_authorization_challenge( + &self, + request: &MycAuthorizationChallengeRequest, + ) -> Result<MycAuthorizationChallengeAdmission, MycStateRepositoryError> { + let request = request.owned(); + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + verify_metadata(transaction, &expected).await?; + issue_challenge(transaction, &request).await + }) + }) + .await + .map_err(map_transaction_error) + } + + /// Authorizes or expires an exact bound challenge, replaying terminal state safely. + pub async fn authorize_challenge( + &self, + challenge_id: MycAuthorizationChallengeId, + connection_id: MycConnectionId, + operation_id: MycSignerOperationId, + policy_generation: MycConnectionPolicyGeneration, + observed_at: MycConnectionTimeUnixMs, + ) -> Result<MycAuthorizationChallengeRecord, MycStateRepositoryError> { + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + verify_metadata(transaction, &expected).await?; + authorize_challenge( + transaction, + challenge_id, + connection_id, + operation_id, + policy_generation, + observed_at, + ) + .await + }) + }) + .await + .map_err(map_transaction_error) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConnectionOperationError { + Binding, + Storage, +} + +async fn verify_metadata( + transaction: &mut ServiceSqliteTransaction<'_>, + expected: &PersistedMetadata, +) -> Result<(), ConnectionOperationError> { + require_expected_metadata(transaction, expected) + .await + .map_err(|error| match error { + RepositoryOperationError::Binding => ConnectionOperationError::Binding, + RepositoryOperationError::Storage => ConnectionOperationError::Storage, + }) +} + +async fn admit_connection( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycConnectionAdmissionRequest, +) -> Result<MycConnectionAdmission, ConnectionOperationError> { + let binding = read_request_binding(transaction, request.operation_id).await?; + if binding.client_public_key != request.client_public_key + || binding.method != MycSignerRequestMethod::Connect + || request.observed_at < binding.received_at + { + return Err(ConnectionOperationError::Binding); + } + if let Some(existing) = read_decision(transaction, request.operation_id).await? { + if existing.policy_generation != request.policy_generation + || existing.admission_policy != Some(request.policy) + || existing.requested_permissions_sha256 != *request.requested_permissions.digest() + { + return Err(ConnectionOperationError::Binding); + } + let record = decision_record(transaction, request.operation_id, existing).await?; + if record + .connection() + .is_some_and(|connection| connection.client_public_key != request.client_public_key) + { + return Err(ConnectionOperationError::Binding); + } + return Ok(MycConnectionAdmission::ExactReplay(record)); + } + + let decision = request.policy.decision(); + let connection_id = if request.policy == MycConnectionAdmissionPolicy::Denied { + None + } else { + let id = derive_connection_id(&request.client_public_key, &request.nonce); + insert_connection(transaction, request, id).await?; + Some(id) + }; + insert_decision( + transaction, + request.operation_id, + connection_id, + decision, + decision.reason(request.policy), + request.policy_generation, + request.requested_permissions.digest(), + None, + request.observed_at, + ) + .await?; + let persisted = read_decision(transaction, request.operation_id) + .await? + .ok_or(ConnectionOperationError::Binding)?; + decision_record(transaction, request.operation_id, persisted) + .await + .map(MycConnectionAdmission::Admitted) +} + +async fn insert_connection( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycConnectionAdmissionRequest, + id: MycConnectionId, +) -> Result<(), ConnectionOperationError> { + let status = match request.policy { + MycConnectionAdmissionPolicy::Trusted => MycConnectionStatus::Active, + MycConnectionAdmissionPolicy::ExplicitApproval => MycConnectionStatus::Pending, + MycConnectionAdmissionPolicy::Denied => return Err(ConnectionOperationError::Binding), + }; + let result = sqlx::query(INSERT_CONNECTION_SQL) + .bind(id.as_bytes().as_slice()) + .bind(request.nonce.0.as_slice()) + .bind(request.client_public_key.as_hex()) + .bind(request.requested_permissions.digest().as_slice()) + .bind(request.policy_generation.sqlite_value()) + .bind(status.as_str()) + .bind(request.observed_at.sqlite_value()) + .bind(request.observed_at.sqlite_value()) + .bind( + request + .authorized_until + .map(MycConnectionTimeUnixMs::sqlite_value), + ) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected())?; + insert_permissions(transaction, id, "requested", &request.requested_permissions).await?; + if status == MycConnectionStatus::Active { + insert_permissions(transaction, id, "granted", &request.requested_permissions).await?; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn insert_decision( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: MycSignerOperationId, + connection_id: Option<MycConnectionId>, + decision: MycConnectionDecision, + reason: &'static str, + policy_generation: MycConnectionPolicyGeneration, + permissions_digest: &[u8; 32], + challenge_id: Option<MycAuthorizationChallengeId>, + decided_at: MycConnectionTimeUnixMs, +) -> Result<(), ConnectionOperationError> { + let result = sqlx::query(INSERT_DECISION_SQL) + .bind(operation_id.as_bytes().as_slice()) + .bind(connection_id.map(|value| value.0.to_vec())) + .bind(decision.as_str()) + .bind(reason) + .bind(policy_generation.sqlite_value()) + .bind(permissions_digest.as_slice()) + .bind(challenge_id.map(|value| value.0.to_vec())) + .bind(decided_at.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected()) +} + +async fn decide_connection( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: MycSignerOperationId, + connection_id: MycConnectionId, + policy_generation: MycConnectionPolicyGeneration, + observed_at: MycConnectionTimeUnixMs, + decision: MycConnectionOperatorDecision, +) -> Result<MycConnectionRecord, ConnectionOperationError> { + let existing_decision = read_decision(transaction, operation_id) + .await? + .ok_or(ConnectionOperationError::Binding)?; + if existing_decision.connection_id != Some(connection_id) + || existing_decision.policy_generation != policy_generation + { + return Err(ConnectionOperationError::Binding); + } + let connection = read_connection(transaction, connection_id).await?; + if connection.policy_generation != policy_generation { + return Err(ConnectionOperationError::Binding); + } + + match decision { + MycConnectionOperatorDecision::Approve { + granted_permissions, + authorized_until, + } => { + if !granted_permissions.is_subset_of(&connection.requested_permissions) + || authorized_until.is_some_and(|until| until <= observed_at) + { + return Err(ConnectionOperationError::Binding); + } + if connection.status == MycConnectionStatus::Active + && existing_decision.decision == MycConnectionDecision::Allowed + { + return (connection.granted_permissions == granted_permissions + && connection.authorized_until == authorized_until) + .then_some(connection) + .ok_or(ConnectionOperationError::Binding); + } + if connection.status != MycConnectionStatus::Pending + || existing_decision.decision != MycConnectionDecision::PendingApproval + || observed_at < connection.updated_at + { + return Err(ConnectionOperationError::Binding); + } + insert_permissions(transaction, connection_id, "granted", &granted_permissions).await?; + let result = sqlx::query(APPROVE_CONNECTION_SQL) + .bind(observed_at.sqlite_value()) + .bind(authorized_until.map(MycConnectionTimeUnixMs::sqlite_value)) + .bind(connection_id.as_bytes().as_slice()) + .bind(policy_generation.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected())?; + update_approval_decision( + transaction, + operation_id, + connection_id, + policy_generation, + observed_at, + MycConnectionDecision::Allowed, + "operator_approved", + ) + .await?; + } + MycConnectionOperatorDecision::Deny => { + if connection.status == MycConnectionStatus::Denied + && existing_decision.decision == MycConnectionDecision::Denied + { + return Ok(connection); + } + if connection.status != MycConnectionStatus::Pending + || existing_decision.decision != MycConnectionDecision::PendingApproval + || observed_at < connection.updated_at + { + return Err(ConnectionOperationError::Binding); + } + let result = sqlx::query(DENY_CONNECTION_SQL) + .bind(observed_at.sqlite_value()) + .bind(connection_id.as_bytes().as_slice()) + .bind(policy_generation.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected())?; + update_approval_decision( + transaction, + operation_id, + connection_id, + policy_generation, + observed_at, + MycConnectionDecision::Denied, + "operator_denied", + ) + .await?; + } + } + read_connection(transaction, connection_id).await +} + +#[allow(clippy::too_many_arguments)] +async fn update_approval_decision( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: MycSignerOperationId, + connection_id: MycConnectionId, + policy_generation: MycConnectionPolicyGeneration, + observed_at: MycConnectionTimeUnixMs, + decision: MycConnectionDecision, + reason: &'static str, +) -> Result<(), ConnectionOperationError> { + let result = sqlx::query(UPDATE_APPROVAL_DECISION_SQL) + .bind(decision.as_str()) + .bind(reason) + .bind(observed_at.sqlite_value()) + .bind(operation_id.as_bytes().as_slice()) + .bind(connection_id.as_bytes().as_slice()) + .bind(policy_generation.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected()) +} + +async fn issue_challenge( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycAuthorizationChallengeRequest, +) -> Result<MycAuthorizationChallengeAdmission, ConnectionOperationError> { + let binding = read_request_binding(transaction, request.operation_id).await?; + if binding.method == MycSignerRequestMethod::Connect { + return Err(ConnectionOperationError::Binding); + } + let connection = read_connection(transaction, request.connection_id).await?; + if connection.client_public_key != binding.client_public_key + || connection.policy_generation != request.policy_generation + { + return Err(ConnectionOperationError::Binding); + } + if let Some(existing) = read_challenge(transaction, request.operation_id).await? { + if existing.connection_id != request.connection_id + || existing.policy_generation != request.policy_generation + || existing.url != request.url + || existing.issued_at != request.issued_at + || existing.expires_at != request.expires_at + { + return Err(ConnectionOperationError::Binding); + } + return Ok(MycAuthorizationChallengeAdmission::ExactReplay(existing)); + } + if request.issued_at < binding.received_at + || request.issued_at < connection.updated_at + || connection.status != MycConnectionStatus::Active + || connection + .authorized_until + .is_some_and(|until| until < request.issued_at) + { + return Err(ConnectionOperationError::Binding); + } + if read_decision(transaction, request.operation_id) + .await? + .is_some() + { + return Err(ConnectionOperationError::Binding); + } + let challenge_id = + derive_challenge_id(request.operation_id, request.connection_id, &request.nonce); + let result = sqlx::query(INSERT_CHALLENGE_SQL) + .bind(challenge_id.as_bytes().as_slice()) + .bind(request.nonce.0.as_slice()) + .bind(request.connection_id.as_bytes().as_slice()) + .bind(request.operation_id.as_bytes().as_slice()) + .bind(request.policy_generation.sqlite_value()) + .bind(request.url.as_str()) + .bind(request.issued_at.sqlite_value()) + .bind(request.expires_at.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected())?; + insert_decision( + transaction, + request.operation_id, + Some(request.connection_id), + MycConnectionDecision::Challenged, + "authorization_challenge_required", + request.policy_generation, + connection.requested_permissions.digest(), + Some(challenge_id), + request.issued_at, + ) + .await?; + let record = read_challenge(transaction, request.operation_id) + .await? + .ok_or(ConnectionOperationError::Binding)?; + Ok(MycAuthorizationChallengeAdmission::Created(record)) +} + +#[allow(clippy::too_many_arguments)] +async fn authorize_challenge( + transaction: &mut ServiceSqliteTransaction<'_>, + challenge_id: MycAuthorizationChallengeId, + connection_id: MycConnectionId, + operation_id: MycSignerOperationId, + policy_generation: MycConnectionPolicyGeneration, + observed_at: MycConnectionTimeUnixMs, +) -> Result<MycAuthorizationChallengeRecord, ConnectionOperationError> { + let before = read_challenge(transaction, operation_id) + .await? + .ok_or(ConnectionOperationError::Binding)?; + if before.id != challenge_id + || before.connection_id != connection_id + || before.operation_id != operation_id + || before.policy_generation != policy_generation + { + return Err(ConnectionOperationError::Binding); + } + let request_binding = read_request_binding(transaction, operation_id).await?; + let connection = read_connection(transaction, connection_id).await?; + if request_binding.method == MycSignerRequestMethod::Connect + || request_binding.client_public_key != connection.client_public_key + || connection.policy_generation != policy_generation + || observed_at < before.issued_at + { + return Err(ConnectionOperationError::Binding); + } + if before.state != MycAuthorizationChallengeState::Pending { + return Ok(before); + } + let connection_expired = connection.status != MycConnectionStatus::Active + || connection + .authorized_until + .is_some_and(|until| until < observed_at); + let (state, decision, reason) = if observed_at > before.expires_at || connection_expired { + ( + MycAuthorizationChallengeState::Expired, + MycConnectionDecision::Denied, + "authorization_challenge_expired", + ) + } else { + ( + MycAuthorizationChallengeState::Authorized, + MycConnectionDecision::Allowed, + "authorization_challenge_authorized", + ) + }; + let result = sqlx::query(RESOLVE_CHALLENGE_SQL) + .bind(state.as_str()) + .bind(observed_at.sqlite_value()) + .bind(challenge_id.as_bytes().as_slice()) + .bind(connection_id.as_bytes().as_slice()) + .bind(operation_id.as_bytes().as_slice()) + .bind(policy_generation.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected())?; + let result = sqlx::query(UPDATE_CHALLENGE_DECISION_SQL) + .bind(decision.as_str()) + .bind(reason) + .bind(observed_at.sqlite_value()) + .bind(operation_id.as_bytes().as_slice()) + .bind(connection_id.as_bytes().as_slice()) + .bind(challenge_id.as_bytes().as_slice()) + .bind(policy_generation.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected())?; + read_challenge(transaction, operation_id) + .await? + .ok_or(ConnectionOperationError::Binding) +} + +struct RequestBinding { + client_public_key: MycNip46ClientPublicKey, + method: MycSignerRequestMethod, + received_at: MycConnectionTimeUnixMs, +} + +async fn read_request_binding( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: MycSignerOperationId, +) -> Result<RequestBinding, ConnectionOperationError> { + let rows = sqlx::query(READ_REQUEST_BINDING_SQL) + .bind(operation_id.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + if rows.len() != 1 { + return Err(ConnectionOperationError::Binding); + } + let row = &rows[0]; + let client = row + .try_get::<Option<&str>, _>("client_public_key") + .map_err(|_| ConnectionOperationError::Binding)? + .ok_or(ConnectionOperationError::Binding) + .and_then(|value| { + MycNip46ClientPublicKey::new(value).map_err(|_| ConnectionOperationError::Binding) + })?; + let method = row + .try_get::<Option<&str>, _>("method") + .map_err(|_| ConnectionOperationError::Binding)? + .and_then(MycSignerRequestMethod::parse) + .ok_or(ConnectionOperationError::Binding)?; + Ok(RequestBinding { + client_public_key: client, + method, + received_at: time(row, "received_at_unix_ms")?, + }) +} + +#[derive(Clone, Copy)] +struct PersistedDecision { + connection_id: Option<MycConnectionId>, + decision: MycConnectionDecision, + policy_generation: MycConnectionPolicyGeneration, + requested_permissions_sha256: [u8; 32], + challenge_id: Option<MycAuthorizationChallengeId>, + decided_at: MycConnectionTimeUnixMs, + admission_policy: Option<MycConnectionAdmissionPolicy>, +} + +async fn read_decision( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: MycSignerOperationId, +) -> Result<Option<PersistedDecision>, ConnectionOperationError> { + let rows = sqlx::query(READ_DECISION_SQL) + .bind(operation_id.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + if rows.len() > 1 { + return Err(ConnectionOperationError::Binding); + } + rows.first().map(parse_decision).transpose() +} + +fn parse_decision( + row: &sqlx::sqlite::SqliteRow, +) -> Result<PersistedDecision, ConnectionOperationError> { + let connection_id = + optional_digest(row, "connection_id", "connection_id_type")?.map(MycConnectionId); + let challenge_id = + optional_digest(row, "challenge_id", "challenge_id_type")?.map(MycAuthorizationChallengeId); + let decision = bounded_text(row, "decision").and_then(|value| { + MycConnectionDecision::parse(value).ok_or(ConnectionOperationError::Binding) + })?; + let reason = bounded_text(row, "reason_code")?; + if !matches!( + (decision, reason, connection_id, challenge_id), + (MycConnectionDecision::Denied, "policy_denied", None, None) + | ( + MycConnectionDecision::PendingApproval, + "explicit_approval_required", + Some(_), + None + ) + | ( + MycConnectionDecision::Allowed, + "trusted_client", + Some(_), + None + ) + | ( + MycConnectionDecision::Allowed, + "operator_approved", + Some(_), + None + ) + | ( + MycConnectionDecision::Denied, + "operator_denied", + Some(_), + None + ) + | ( + MycConnectionDecision::Challenged, + "authorization_challenge_required", + Some(_), + Some(_) + ) + | ( + MycConnectionDecision::Allowed, + "authorization_challenge_authorized", + Some(_), + Some(_) + ) + | ( + MycConnectionDecision::Denied, + "authorization_challenge_expired", + Some(_), + Some(_) + ) + ) { + return Err(ConnectionOperationError::Binding); + } + let admission_policy = match reason { + "trusted_client" => Some(MycConnectionAdmissionPolicy::Trusted), + "explicit_approval_required" | "operator_approved" | "operator_denied" => { + Some(MycConnectionAdmissionPolicy::ExplicitApproval) + } + "policy_denied" => Some(MycConnectionAdmissionPolicy::Denied), + "authorization_challenge_required" + | "authorization_challenge_authorized" + | "authorization_challenge_expired" => None, + _ => return Err(ConnectionOperationError::Binding), + }; + Ok(PersistedDecision { + connection_id, + decision, + policy_generation: generation(row, "policy_generation")?, + requested_permissions_sha256: exact_digest(row, "requested_permissions_sha256")?, + challenge_id, + decided_at: time(row, "decided_at_unix_ms")?, + admission_policy, + }) +} + +async fn decision_record( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: MycSignerOperationId, + decision: PersistedDecision, +) -> Result<MycConnectionDecisionRecord, ConnectionOperationError> { + let connection = match decision.connection_id { + Some(id) => Some(read_connection(transaction, id).await?), + None => None, + }; + if connection.as_ref().is_some_and(|value| { + value.requested_permissions.digest() != &decision.requested_permissions_sha256 + }) || decision.challenge_id.is_some() + { + return Err(ConnectionOperationError::Binding); + } + let state_matches = match (decision.admission_policy, decision.decision, &connection) { + (Some(MycConnectionAdmissionPolicy::Denied), MycConnectionDecision::Denied, None) => true, + ( + Some(MycConnectionAdmissionPolicy::Trusted), + MycConnectionDecision::Allowed, + Some(connection), + ) => matches!( + connection.status, + MycConnectionStatus::Active | MycConnectionStatus::Expired + ), + ( + Some(MycConnectionAdmissionPolicy::ExplicitApproval), + MycConnectionDecision::PendingApproval, + Some(connection), + ) => connection.status == MycConnectionStatus::Pending, + ( + Some(MycConnectionAdmissionPolicy::ExplicitApproval), + MycConnectionDecision::Allowed, + Some(connection), + ) => matches!( + connection.status, + MycConnectionStatus::Active | MycConnectionStatus::Expired + ), + ( + Some(MycConnectionAdmissionPolicy::ExplicitApproval), + MycConnectionDecision::Denied, + Some(connection), + ) => connection.status == MycConnectionStatus::Denied, + _ => false, + }; + if !state_matches { + return Err(ConnectionOperationError::Binding); + } + Ok(MycConnectionDecisionRecord { + operation_id, + connection, + decision: decision.decision, + policy_generation: decision.policy_generation, + decided_at: decision.decided_at, + }) +} + +async fn read_connection( + transaction: &mut ServiceSqliteTransaction<'_>, + id: MycConnectionId, +) -> Result<MycConnectionRecord, ConnectionOperationError> { + let rows = sqlx::query(READ_CONNECTION_SQL) + .bind(id.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + if rows.len() != 1 { + return Err(ConnectionOperationError::Binding); + } + let row = &rows[0]; + let actual_id = MycConnectionId(exact_digest(row, "connection_id")?); + let nonce = exact_digest(row, "connection_nonce")?; + let client_public_key = MycNip46ClientPublicKey::new(bounded_text(row, "client_public_key")?) + .map_err(|_| ConnectionOperationError::Binding)?; + let requested_permissions = read_permissions(transaction, id, "requested").await?; + let granted_permissions = read_permissions(transaction, id, "granted").await?; + let requested_digest = exact_digest(row, "requested_permissions_sha256")?; + let policy_generation = generation(row, "policy_generation")?; + let status = MycConnectionStatus::parse(bounded_text(row, "status")?) + .ok_or(ConnectionOperationError::Binding)?; + let created_at = time(row, "created_at_unix_ms")?; + let updated_at = time(row, "updated_at_unix_ms")?; + let authorized_until = optional_time(row, "authorized_until_unix_ms", "authorized_until_type")?; + if actual_id != id + || derive_connection_id(&client_public_key, &MycConnectionNonce(nonce)) != id + || requested_permissions.digest() != &requested_digest + || !granted_permissions.is_subset_of(&requested_permissions) + || updated_at < created_at + || (matches!( + status, + MycConnectionStatus::Pending | MycConnectionStatus::Denied + ) && !granted_permissions.permissions.is_empty()) + || (status == MycConnectionStatus::Active + && authorized_until.is_some_and(|until| until <= updated_at)) + || (matches!( + status, + MycConnectionStatus::Denied | MycConnectionStatus::Expired + ) && authorized_until.is_some()) + { + return Err(ConnectionOperationError::Binding); + } + Ok(MycConnectionRecord { + id, + client_public_key, + requested_permissions, + granted_permissions, + policy_generation, + status, + created_at, + updated_at, + authorized_until, + }) +} + +async fn insert_permissions( + transaction: &mut ServiceSqliteTransaction<'_>, + connection_id: MycConnectionId, + scope: &'static str, + permissions: &MycConnectionPermissionSet, +) -> Result<(), ConnectionOperationError> { + for permission in permissions.permissions() { + let result = sqlx::query(INSERT_PERMISSION_SQL) + .bind(connection_id.as_bytes().as_slice()) + .bind(scope) + .bind(permission.code()) + .execute(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + require_one(result.rows_affected())?; + } + Ok(()) +} + +async fn read_permissions( + transaction: &mut ServiceSqliteTransaction<'_>, + connection_id: MycConnectionId, + scope: &'static str, +) -> Result<MycConnectionPermissionSet, ConnectionOperationError> { + let rows = sqlx::query(READ_PERMISSIONS_SQL) + .bind(connection_id.as_bytes().as_slice()) + .bind(scope) + .fetch_all(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + if rows.len() > MYC_CONNECTION_PERMISSION_MAX_COUNT { + return Err(ConnectionOperationError::Binding); + } + let permissions = rows + .iter() + .map(|row| { + bounded_text(row, "permission_code").and_then(|value| { + MycConnectionPermission::parse(value).ok_or(ConnectionOperationError::Binding) + }) + }) + .collect::<Result<Vec<_>, _>>()?; + MycConnectionPermissionSet::new(&permissions).map_err(|_| ConnectionOperationError::Binding) +} + +async fn read_challenge( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: MycSignerOperationId, +) -> Result<Option<MycAuthorizationChallengeRecord>, ConnectionOperationError> { + let rows = sqlx::query(READ_CHALLENGE_SQL) + .bind(operation_id.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| ConnectionOperationError::Storage)?; + if rows.len() > 1 { + return Err(ConnectionOperationError::Binding); + } + rows.first().map(parse_challenge).transpose() +} + +fn parse_challenge( + row: &sqlx::sqlite::SqliteRow, +) -> Result<MycAuthorizationChallengeRecord, ConnectionOperationError> { + let id = MycAuthorizationChallengeId(exact_digest(row, "challenge_id")?); + let nonce = exact_digest(row, "challenge_nonce")?; + let connection_id = MycConnectionId(exact_digest(row, "connection_id")?); + let operation_id = MycSignerOperationId::from_persisted(exact_digest(row, "operation_id")?); + let policy_generation = generation(row, "policy_generation")?; + let url = MycAuthorizationChallengeUrl::new(bounded_text(row, "challenge_url")?) + .map_err(|_| ConnectionOperationError::Binding)?; + let state = MycAuthorizationChallengeState::parse(bounded_text(row, "state")?) + .ok_or(ConnectionOperationError::Binding)?; + let issued_at = time(row, "issued_at_unix_ms")?; + let expires_at = time(row, "expires_at_unix_ms")?; + let resolved_at = optional_time(row, "resolved_at_unix_ms", "resolved_at_type")?; + if derive_challenge_id( + operation_id, + connection_id, + &MycAuthorizationChallengeNonce(nonce), + ) != id + || expires_at <= issued_at + || (state == MycAuthorizationChallengeState::Pending && resolved_at.is_some()) + || (state != MycAuthorizationChallengeState::Pending + && resolved_at.is_none_or(|resolved| resolved < issued_at)) + { + return Err(ConnectionOperationError::Binding); + } + Ok(MycAuthorizationChallengeRecord { + id, + operation_id, + connection_id, + policy_generation, + url, + state, + issued_at, + expires_at, + resolved_at, + }) +} + +fn permission_digest(permissions: &[MycConnectionPermission]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(PERMISSION_SET_DOMAIN); + hasher.update( + u64::try_from(permissions.len()) + .expect("bounded permission count") + .to_be_bytes(), + ); + for permission in permissions { + let code = permission.code(); + hasher.update( + u64::try_from(code.len()) + .expect("bounded permission code") + .to_be_bytes(), + ); + hasher.update(code.as_bytes()); + } + hasher.finalize().into() +} + +fn derive_connection_id( + client: &MycNip46ClientPublicKey, + nonce: &MycConnectionNonce, +) -> MycConnectionId { + let mut hasher = Sha256::new(); + hasher.update(CONNECTION_ID_DOMAIN); + hasher.update(client.as_hex().as_bytes()); + hasher.update(nonce.0); + MycConnectionId(hasher.finalize().into()) +} + +fn derive_challenge_id( + operation_id: MycSignerOperationId, + connection_id: MycConnectionId, + nonce: &MycAuthorizationChallengeNonce, +) -> MycAuthorizationChallengeId { + let mut hasher = Sha256::new(); + hasher.update(CHALLENGE_ID_DOMAIN); + hasher.update(operation_id.as_bytes()); + hasher.update(connection_id.as_bytes()); + hasher.update(nonce.0); + MycAuthorizationChallengeId(hasher.finalize().into()) +} + +fn exact_digest( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<[u8; 32], ConnectionOperationError> { + row.try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| ConnectionOperationError::Binding)? + .ok_or(ConnectionOperationError::Binding)? + .try_into() + .map_err(|_| ConnectionOperationError::Binding) +} + +fn optional_digest( + row: &sqlx::sqlite::SqliteRow, + column: &str, + type_column: &str, +) -> Result<Option<[u8; 32]>, ConnectionOperationError> { + let kind = row + .try_get::<&str, _>(type_column) + .map_err(|_| ConnectionOperationError::Binding)?; + match kind { + "null" => Ok(None), + "blob" => exact_digest(row, column).map(Some), + _ => Err(ConnectionOperationError::Binding), + } +} + +fn bounded_text<'row>( + row: &'row sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<&'row str, ConnectionOperationError> { + row.try_get::<Option<&str>, _>(column) + .map_err(|_| ConnectionOperationError::Binding)? + .ok_or(ConnectionOperationError::Binding) +} + +fn generation( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<MycConnectionPolicyGeneration, ConnectionOperationError> { + let value = row + .try_get::<i64, _>(column) + .map_err(|_| ConnectionOperationError::Binding)?; + MycConnectionPolicyGeneration::new( + u64::try_from(value).map_err(|_| ConnectionOperationError::Binding)?, + ) + .map_err(|_| ConnectionOperationError::Binding) +} + +fn time( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<MycConnectionTimeUnixMs, ConnectionOperationError> { + let value = row + .try_get::<i64, _>(column) + .map_err(|_| ConnectionOperationError::Binding)?; + MycConnectionTimeUnixMs::new( + u64::try_from(value).map_err(|_| ConnectionOperationError::Binding)?, + ) + .map_err(|_| ConnectionOperationError::Binding) +} + +fn optional_time( + row: &sqlx::sqlite::SqliteRow, + column: &str, + type_column: &str, +) -> Result<Option<MycConnectionTimeUnixMs>, ConnectionOperationError> { + match row + .try_get::<&str, _>(type_column) + .map_err(|_| ConnectionOperationError::Binding)? + { + "null" => Ok(None), + "integer" => time(row, column).map(Some), + _ => Err(ConnectionOperationError::Binding), + } +} + +fn require_one(rows: u64) -> Result<(), ConnectionOperationError> { + (rows == 1) + .then_some(()) + .ok_or(ConnectionOperationError::Storage) +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<ConnectionOperationError>, +) -> MycStateRepositoryError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return MycStateRepositoryError::new(MycStateRepositoryErrorKind::CommitOutcomeUnknown); + } + let kind = match error.operation_error() { + Some(ConnectionOperationError::Binding) => MycStateRepositoryErrorKind::Binding, + Some(ConnectionOperationError::Storage) | None => MycStateRepositoryErrorKind::Transaction, + }; + MycStateRepositoryError::new(kind) +} diff --git a/src/state_host.rs b/src/state_host.rs @@ -377,14 +377,14 @@ fn require_migration_build( fn exact_initialization_outcome(outcome: MigrationApplicationOutcome) -> bool { outcome.initial_version() == MYC_STATE_BASE_SCHEMA_VERSION && outcome.final_version() == MYC_STATE_SCHEMA_VERSION - && outcome.applied_count() == 2 + && outcome.applied_count() == 3 } fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool { outcome.final_version() == MYC_STATE_SCHEMA_VERSION && matches!( (outcome.initial_version(), outcome.applied_count()), - (MYC_STATE_BASE_SCHEMA_VERSION, 2) | (2, 1) | (MYC_STATE_SCHEMA_VERSION, 0) + (MYC_STATE_BASE_SCHEMA_VERSION, 3) | (2, 2) | (3, 1) | (MYC_STATE_SCHEMA_VERSION, 0) ) } diff --git a/src/state_request.rs b/src/state_request.rs @@ -247,6 +247,12 @@ redacted_digest!(MycSignerRequestDigest); redacted_digest!(MycSignerOperationId); redacted_digest!(MycSignerCorrelationId); +impl MycSignerOperationId { + pub(crate) const fn from_persisted(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + /// One-use entropy evidence for a new logical signer operation. /// /// The runtime obtains these bytes from its injected entropy source. Admission @@ -332,7 +338,7 @@ impl MycSignerRequestMethod { } } - fn parse(value: &str) -> Option<Self> { + pub(crate) fn parse(value: &str) -> Option<Self> { match value { "connect" => Some(Self::Connect), "get_public_key" => Some(Self::GetPublicKey), diff --git a/tests/services_hardening_connection_state.rs b/tests/services_hardening_connection_state.rs @@ -0,0 +1,929 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path}; + +use myc::{ + MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES, MYC_CONNECTION_PERMISSION_MAX_COUNT, + MYC_STATE_SCHEMA_VERSION, MycAuthorizationChallengeAdmission, MycAuthorizationChallengeNonce, + MycAuthorizationChallengeRequest, MycAuthorizationChallengeState, MycAuthorizationChallengeUrl, + MycConfigProfile, MycConnectionAdmission, MycConnectionAdmissionPolicy, + MycConnectionAdmissionRequest, MycConnectionNonce, MycConnectionOperatorDecision, + MycConnectionPermission, MycConnectionPermissionSet, MycConnectionPolicyGeneration, + MycConnectionStateErrorKind, MycConnectionStatus, MycConnectionTimeUnixMs, + MycNip46ClientPublicKey, MycNip46EventId, MycNip46RequestId, MycRequestReceivedAtUnixMs, + MycSignerOperationId, MycSignerOperationNonce, MycSignerRequest, MycSignerRequestDigest, + MycSignerRequestMethod, MycStateMetadata, MycStateRepository, MycStateRepositoryErrorKind, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, initialize_myc_state, + open_myc_state_read_write, parse_myc_cli_v1_from, parse_myc_config_v1, + resolve_myc_runtime_context, +}; +use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; +use radroots_storage::event::SourceGeneration; +use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions}; + +const CONFIG_EXAMPLE: &[u8] = + include_bytes!("../contracts/services_hardening/config.v1.example.toml"); +const CONNECTION_SOURCE: &str = include_str!("../src/state_connection.rs"); +const CLIENT_PUBLIC_KEY: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + +fn runtime(root: &Path) -> myc::MycRuntimeContext { + let root = root.to_str().expect("UTF-8 temporary root"); + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "repo-local", + "--instance", + "primary", + "--repo-local-root", + root, + "run", + ]) + .expect("valid test invocation"); + resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime context") +} + +fn prepare_state_directory(runtime: &myc::MycRuntimeContext) { + let directory = runtime.context().paths().state(); + fs::create_dir_all(directory).expect("state directory"); + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode"); +} + +fn metadata(runtime: &myc::MycRuntimeContext) -> MycStateMetadata { + let configuration = + parse_myc_config_v1(CONFIG_EXAMPLE, MycConfigProfile::RepoLocal).expect("configuration"); + MycStateMetadata::new( + runtime, + &configuration, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1_725_000_000_000, + ) + .expect("metadata") +} + +fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { + let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time"); + let build = MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "b44119fbac5985be8127ad1bf56d2950e6399427", + "rustc-test", + "test-target", + "service-host", + 1, + MYC_STATE_SCHEMA_VERSION, + 1, + 1, + 1, + ) + .expect("build identity"); + (applied_at, build) +} + +fn client() -> MycNip46ClientPublicKey { + MycNip46ClientPublicKey::new(CLIENT_PUBLIC_KEY).expect("client identity") +} + +fn permission_set(permissions: &[MycConnectionPermission]) -> MycConnectionPermissionSet { + MycConnectionPermissionSet::new(permissions).expect("permission set") +} + +fn time(value: u64) -> MycConnectionTimeUnixMs { + MycConnectionTimeUnixMs::new(value).expect("connection time") +} + +fn policy_generation(value: u64) -> MycConnectionPolicyGeneration { + MycConnectionPolicyGeneration::new(value).expect("policy generation") +} + +async fn admit_request( + repository: &MycStateRepository<'_>, + request_id: &str, + event_byte: u8, + method: MycSignerRequestMethod, + nonce_byte: u8, + received_at: u64, +) -> MycSignerOperationId { + let canonical = format!( + "{{\"id\":\"{request_id}\",\"method\":\"{}\"}}", + method.as_str() + ); + let request = MycSignerRequest::new( + client(), + MycNip46RequestId::new(request_id).expect("request ID"), + MycNip46EventId::from_bytes([event_byte; 32]), + method, + MycSignerRequestDigest::for_canonical_request(canonical.as_bytes()) + .expect("request digest"), + MycSignerOperationNonce::from_injected_entropy([nonce_byte; 32]), + MycRequestReceivedAtUnixMs::new(received_at).expect("received time"), + ); + repository + .admit_signer_request(&request) + .await + .expect("request admission") + .record() + .operation_id() +} + +fn connection_request( + operation_id: MycSignerOperationId, + permissions: MycConnectionPermissionSet, + generation: u64, + nonce_byte: u8, + observed_at: u64, + authorized_until: Option<u64>, + policy: MycConnectionAdmissionPolicy, +) -> MycConnectionAdmissionRequest { + MycConnectionAdmissionRequest::new( + operation_id, + client(), + permissions, + policy_generation(generation), + MycConnectionNonce::from_injected_entropy([nonce_byte; 32]), + time(observed_at), + authorized_until.map(time), + policy, + ) + .expect("connection request") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[test] +fn connection_inputs_are_closed_bounded_canonical_and_redacted() { + let maximum = (0..MYC_CONNECTION_PERMISSION_MAX_COUNT) + .map(|kind| MycConnectionPermission::SignEvent(u16::try_from(kind).expect("kind"))) + .collect::<Vec<_>>(); + let permissions = MycConnectionPermissionSet::new(&maximum).expect("maximum permissions"); + assert_eq!( + permissions.permissions().len(), + MYC_CONNECTION_PERMISSION_MAX_COUNT + ); + assert_eq!( + MycConnectionPermissionSet::new(&[ + MycConnectionPermission::Ping, + MycConnectionPermission::Ping, + ]) + .expect_err("duplicate permission") + .kind(), + MycConnectionStateErrorKind::InvalidPermissionSet + ); + let excessive = (0..=MYC_CONNECTION_PERMISSION_MAX_COUNT) + .map(|kind| MycConnectionPermission::SignEvent(u16::try_from(kind).expect("kind"))) + .collect::<Vec<_>>(); + assert_eq!( + MycConnectionPermissionSet::new(&excessive) + .expect_err("excessive permissions") + .kind(), + MycConnectionStateErrorKind::InvalidPermissionSet + ); + assert!(MycConnectionPermissionSet::new(&[]).is_ok()); + + assert!(MycConnectionPolicyGeneration::new(i64::MAX.unsigned_abs()).is_ok()); + assert!(MycConnectionTimeUnixMs::new(i64::MAX.unsigned_abs()).is_ok()); + for invalid in [0, i64::MAX.unsigned_abs() + 1] { + assert_eq!( + MycConnectionPolicyGeneration::new(invalid) + .expect_err("invalid policy generation") + .kind(), + MycConnectionStateErrorKind::InvalidPolicyGeneration + ); + assert_eq!( + MycConnectionTimeUnixMs::new(invalid) + .expect_err("invalid time") + .kind(), + MycConnectionStateErrorKind::InvalidTime + ); + } + + for accepted in [ + "https://operator.example/authorize", + "http://localhost:8080/authorize", + "http://127.0.0.1/authorize", + "http://[::1]/authorize", + ] { + assert_eq!( + MycAuthorizationChallengeUrl::new(accepted) + .expect("accepted URL") + .as_str(), + accepted + ); + } + let maximum_url = format!( + "https://operator.example/{}", + "a".repeat(MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES - 25) + ); + assert_eq!(maximum_url.len(), MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES); + assert!(MycAuthorizationChallengeUrl::new(&maximum_url).is_ok()); + for invalid in [ + "", + "https://operator.example", + "http://operator.example/authorize", + "https://user@operator.example/authorize", + "https://operator.example/authorize#fragment", + &format!("https://operator.example/{}", "a".repeat(2_100)), + ] { + assert_eq!( + MycAuthorizationChallengeUrl::new(invalid) + .expect_err("invalid URL") + .kind(), + MycConnectionStateErrorKind::InvalidChallengeUrl + ); + } + + let error = MycConnectionPolicyGeneration::new(0).expect_err("invalid generation"); + assert!(Error::source(&error).is_none()); + let rendered = format!( + "{permissions:?} {:?} {:?} {error} {error:?}", + MycConnectionNonce::from_injected_entropy([0x5a; 32]), + MycAuthorizationChallengeUrl::new("https://operator.example/secret").expect("URL") + ); + for secret in ["operator.example", "secret", "5a5a5a", CLIENT_PUBLIC_KEY] { + assert!(!rendered.contains(secret)); + } +} + +#[tokio::test] +async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_denial_direct() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("state initialization"); + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("writable host"); + let repository = host.repository(); + + let trusted_operation = admit_request( + &repository, + "connect-trusted", + 0x10, + MycSignerRequestMethod::Connect, + 0x11, + 100, + ) + .await; + let requested = permission_set(&[ + MycConnectionPermission::Ping, + MycConnectionPermission::SignEvent(1), + ]); + assert_eq!( + repository + .admit_connection(&connection_request( + trusted_operation, + requested.clone(), + 1, + 0x1f, + 99, + Some(1_000), + MycConnectionAdmissionPolicy::Trusted, + )) + .await + .expect_err("connection admission cannot predate the request") + .kind(), + MycStateRepositoryErrorKind::Binding + ); + let trusted = repository + .admit_connection(&connection_request( + trusted_operation, + requested.clone(), + 1, + 0x20, + 110, + Some(1_000), + MycConnectionAdmissionPolicy::Trusted, + )) + .await + .expect("trusted admission"); + assert!(matches!(trusted, MycConnectionAdmission::Admitted(_))); + assert_eq!( + trusted.record().decision(), + myc::MycConnectionDecision::Allowed + ); + let trusted_connection = trusted.record().connection().expect("connection"); + assert_eq!(trusted_connection.status(), MycConnectionStatus::Active); + assert_eq!(trusted_connection.granted_permissions(), &requested); + let trusted_id = trusted_connection.id(); + assert_eq!( + hex(trusted_id.as_bytes()), + "8819838c497a75152f7c1cd80b8e3bd7836fb84e002e62280f657a5f74903c5c" + ); + let trusted_replay = repository + .admit_connection(&connection_request( + trusted_operation, + requested.clone(), + 1, + 0x21, + 111, + Some(1_000), + MycConnectionAdmissionPolicy::Trusted, + )) + .await + .expect("trusted replay"); + assert!(matches!( + trusted_replay, + MycConnectionAdmission::ExactReplay(_) + )); + assert_eq!( + trusted_replay + .record() + .connection() + .expect("connection") + .id(), + trusted_id + ); + + let pending_operation = admit_request( + &repository, + "connect-pending", + 0x12, + MycSignerRequestMethod::Connect, + 0x13, + 120, + ) + .await; + let pending = repository + .admit_connection(&connection_request( + pending_operation, + requested.clone(), + 2, + 0x22, + 121, + None, + MycConnectionAdmissionPolicy::ExplicitApproval, + )) + .await + .expect("pending admission"); + assert_eq!( + pending.record().decision(), + myc::MycConnectionDecision::PendingApproval + ); + let pending_id = pending + .record() + .connection() + .expect("pending connection") + .id(); + let granted = permission_set(&[MycConnectionPermission::Ping]); + assert_eq!( + repository + .decide_pending_connection( + pending_operation, + pending_id, + policy_generation(2), + time(120), + MycConnectionOperatorDecision::Approve { + granted_permissions: granted.clone(), + authorized_until: Some(time(900)), + }, + ) + .await + .expect_err("operator decision cannot predate pending state") + .kind(), + MycStateRepositoryErrorKind::Binding + ); + let approved = repository + .decide_pending_connection( + pending_operation, + pending_id, + policy_generation(2), + time(130), + MycConnectionOperatorDecision::Approve { + granted_permissions: granted.clone(), + authorized_until: Some(time(900)), + }, + ) + .await + .expect("operator approval"); + assert_eq!(approved.status(), MycConnectionStatus::Active); + assert_eq!(approved.granted_permissions(), &granted); + let approval_replay = repository + .decide_pending_connection( + pending_operation, + pending_id, + policy_generation(2), + time(131), + MycConnectionOperatorDecision::Approve { + granted_permissions: granted.clone(), + authorized_until: Some(time(900)), + }, + ) + .await + .expect("approval replay"); + assert_eq!(approval_replay, approved); + let admission_after_approval = repository + .admit_connection(&connection_request( + pending_operation, + requested.clone(), + 2, + 0x23, + 132, + None, + MycConnectionAdmissionPolicy::ExplicitApproval, + )) + .await + .expect("admission replay after approval"); + assert!(matches!( + admission_after_approval, + MycConnectionAdmission::ExactReplay(_) + )); + assert_eq!( + admission_after_approval.record().decision(), + myc::MycConnectionDecision::Allowed + ); + + let operator_denied_operation = admit_request( + &repository, + "connect-operator-denied", + 0x16, + MycSignerRequestMethod::Connect, + 0x17, + 135, + ) + .await; + let operator_pending = repository + .admit_connection(&connection_request( + operator_denied_operation, + requested.clone(), + 2, + 0x27, + 136, + None, + MycConnectionAdmissionPolicy::ExplicitApproval, + )) + .await + .expect("operator-denied pending admission"); + let operator_denied_id = operator_pending + .record() + .connection() + .expect("operator-denied connection") + .id(); + let operator_denied = repository + .decide_pending_connection( + operator_denied_operation, + operator_denied_id, + policy_generation(2), + time(137), + MycConnectionOperatorDecision::Deny, + ) + .await + .expect("operator denial"); + assert_eq!(operator_denied.status(), MycConnectionStatus::Denied); + assert_eq!( + repository + .decide_pending_connection( + operator_denied_operation, + operator_denied_id, + policy_generation(2), + time(138), + MycConnectionOperatorDecision::Deny, + ) + .await + .expect("operator denial replay"), + operator_denied + ); + + let denied_operation = admit_request( + &repository, + "connect-denied", + 0x14, + MycSignerRequestMethod::Connect, + 0x15, + 140, + ) + .await; + let denied = repository + .admit_connection(&connection_request( + denied_operation, + requested.clone(), + 3, + 0x24, + 141, + None, + MycConnectionAdmissionPolicy::Denied, + )) + .await + .expect("direct denial"); + assert_eq!( + denied.record().decision(), + myc::MycConnectionDecision::Denied + ); + assert!(denied.record().connection().is_none()); + let denied_replay = repository + .admit_connection(&connection_request( + denied_operation, + requested, + 3, + 0x25, + 142, + None, + MycConnectionAdmissionPolicy::Denied, + )) + .await + .expect("denial replay"); + assert!(matches!( + denied_replay, + MycConnectionAdmission::ExactReplay(_) + )); + assert!(denied_replay.record().connection().is_none()); + + let mismatch = repository + .admit_connection(&connection_request( + denied_operation, + permission_set(&[MycConnectionPermission::Ping]), + 3, + 0x26, + 143, + None, + MycConnectionAdmissionPolicy::ExplicitApproval, + )) + .await + .expect_err("policy mismatch"); + assert_eq!(mismatch.kind(), MycStateRepositoryErrorKind::Binding); + + host.close().await.expect("host close"); + let options = SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .read_only(true) + .disable_statement_logging(); + let mut connection = sqlx::SqliteConnection::connect_with(&options) + .await + .expect("inspection connection"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM connections") + .fetch_one(&mut connection) + .await + .expect("connection count"), + 3 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM nip46_request_decisions WHERE reason_code = 'policy_denied' AND connection_id IS NULL" + ) + .fetch_one(&mut connection) + .await + .expect("direct-denial count"), + 1 + ); + connection.close().await.expect("inspection close"); +} + +#[tokio::test] +async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("state initialization"); + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("writable host"); + let repository = host.repository(); + + let connect_operation = admit_request( + &repository, + "connect-challenge", + 0x30, + MycSignerRequestMethod::Connect, + 0x31, + 200, + ) + .await; + let connection = repository + .admit_connection(&connection_request( + connect_operation, + permission_set(&[MycConnectionPermission::Ping]), + 7, + 0x32, + 201, + Some(500), + MycConnectionAdmissionPolicy::Trusted, + )) + .await + .expect("connection") + .record() + .connection() + .expect("active connection") + .clone(); + + let ping_operation = admit_request( + &repository, + "ping-challenge", + 0x33, + MycSignerRequestMethod::Ping, + 0x34, + 210, + ) + .await; + let invalid_lifetime = MycAuthorizationChallengeRequest::new( + ping_operation, + connection.id(), + policy_generation(7), + MycAuthorizationChallengeUrl::new("https://operator.example/").expect("URL"), + MycAuthorizationChallengeNonce::from_injected_entropy([0x34; 32]), + time(211), + time(211), + ) + .expect_err("invalid lifetime"); + assert_eq!( + invalid_lifetime.kind(), + MycConnectionStateErrorKind::InvalidChallengeLifetime + ); + let challenge_request = MycAuthorizationChallengeRequest::new( + ping_operation, + connection.id(), + policy_generation(7), + MycAuthorizationChallengeUrl::new("https://operator.example/authorize").expect("URL"), + MycAuthorizationChallengeNonce::from_injected_entropy([0x35; 32]), + time(211), + time(300), + ) + .expect("challenge request"); + let premature_request = MycAuthorizationChallengeRequest::new( + ping_operation, + connection.id(), + policy_generation(7), + MycAuthorizationChallengeUrl::new("https://operator.example/authorize").expect("URL"), + MycAuthorizationChallengeNonce::from_injected_entropy([0x34; 32]), + time(209), + time(300), + ) + .expect("structurally valid premature request"); + assert_eq!( + repository + .issue_authorization_challenge(&premature_request) + .await + .expect_err("challenge cannot predate request admission") + .kind(), + MycStateRepositoryErrorKind::Binding + ); + let challenge = repository + .issue_authorization_challenge(&challenge_request) + .await + .expect("challenge"); + assert!(matches!( + challenge, + MycAuthorizationChallengeAdmission::Created(_) + )); + assert_eq!( + challenge.record().state(), + MycAuthorizationChallengeState::Pending + ); + let challenge_id = challenge.record().id(); + assert_eq!( + hex(challenge_id.as_bytes()), + "b0f43d00c70db2bf058d461a2c1ac940ef52cfc76a4d271b7bbc89464fb25abb" + ); + + let replay_request = MycAuthorizationChallengeRequest::new( + ping_operation, + connection.id(), + policy_generation(7), + MycAuthorizationChallengeUrl::new("https://operator.example/authorize").expect("URL"), + MycAuthorizationChallengeNonce::from_injected_entropy([0x36; 32]), + time(211), + time(300), + ) + .expect("replay request"); + let replay = repository + .issue_authorization_challenge(&replay_request) + .await + .expect("challenge replay"); + assert!(matches!( + replay, + MycAuthorizationChallengeAdmission::ExactReplay(_) + )); + assert_eq!(replay.record().id(), challenge_id); + + assert_eq!( + repository + .authorize_challenge( + challenge_id, + connection.id(), + ping_operation, + policy_generation(7), + time(210), + ) + .await + .expect_err("resolution cannot predate challenge issue") + .kind(), + MycStateRepositoryErrorKind::Binding + ); + + let authorized = repository + .authorize_challenge( + challenge_id, + connection.id(), + ping_operation, + policy_generation(7), + time(300), + ) + .await + .expect("authorization at exact deadline"); + assert_eq!( + authorized.state(), + MycAuthorizationChallengeState::Authorized + ); + assert_eq!(authorized.resolved_at(), Some(time(300))); + let terminal_replay = repository + .authorize_challenge( + challenge_id, + connection.id(), + ping_operation, + policy_generation(7), + time(400), + ) + .await + .expect("terminal replay"); + assert_eq!(terminal_replay, authorized); + + let deadline_operation = admit_request( + &repository, + "ping-deadline", + 0x3a, + MycSignerRequestMethod::Ping, + 0x3b, + 215, + ) + .await; + let deadline_request = MycAuthorizationChallengeRequest::new( + deadline_operation, + connection.id(), + policy_generation(7), + MycAuthorizationChallengeUrl::new("https://operator.example/deadline").expect("URL"), + MycAuthorizationChallengeNonce::from_injected_entropy([0x3c; 32]), + time(216), + time(240), + ) + .expect("deadline request"); + let deadline_challenge = repository + .issue_authorization_challenge(&deadline_request) + .await + .expect("deadline challenge"); + let deadline_expired = repository + .authorize_challenge( + deadline_challenge.record().id(), + connection.id(), + deadline_operation, + policy_generation(7), + time(241), + ) + .await + .expect("challenge expiry"); + assert_eq!( + deadline_expired.state(), + MycAuthorizationChallengeState::Expired + ); + + let expiring_operation = admit_request( + &repository, + "ping-expiring", + 0x37, + MycSignerRequestMethod::Ping, + 0x38, + 220, + ) + .await; + let expiring_request = MycAuthorizationChallengeRequest::new( + expiring_operation, + connection.id(), + policy_generation(7), + MycAuthorizationChallengeUrl::new("https://operator.example/expiry").expect("URL"), + MycAuthorizationChallengeNonce::from_injected_entropy([0x39; 32]), + time(221), + time(600), + ) + .expect("expiring request"); + let expiring = repository + .issue_authorization_challenge(&expiring_request) + .await + .expect("expiring challenge"); + let expired = repository + .authorize_challenge( + expiring.record().id(), + connection.id(), + expiring_operation, + policy_generation(7), + time(501), + ) + .await + .expect("connection-expired challenge"); + assert_eq!(expired.state(), MycAuthorizationChallengeState::Expired); + + let expired_connection = repository + .expire_connection(connection.id(), policy_generation(7), time(501)) + .await + .expect("connection expiry"); + assert_eq!(expired_connection.status(), MycConnectionStatus::Expired); + assert_eq!( + repository + .expire_connection(connection.id(), policy_generation(7), time(502)) + .await + .expect("expiry replay"), + expired_connection + ); + let challenge_replay_after_connection_expiry = repository + .issue_authorization_challenge(&replay_request) + .await + .expect("challenge replay after connection expiry"); + assert!(matches!( + challenge_replay_after_connection_expiry, + MycAuthorizationChallengeAdmission::ExactReplay(_) + )); + assert_eq!( + challenge_replay_after_connection_expiry.record(), + &authorized + ); + + let wrong_binding = repository + .authorize_challenge( + challenge_id, + connection.id(), + ping_operation, + policy_generation(8), + time(400), + ) + .await + .expect_err("wrong policy binding"); + assert_eq!(wrong_binding.kind(), MycStateRepositoryErrorKind::Binding); + let rendered = format!( + "{challenge:?} {:?} {wrong_binding} {wrong_binding:?}", + challenge.record() + ); + for secret in [ + "operator.example", + "authorize", + CLIENT_PUBLIC_KEY, + "b0f43d00", + ] { + assert!(!rendered.contains(secret)); + } + assert!(Error::source(&wrong_binding).is_none()); + + host.close().await.expect("host close"); + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("reopened host"); + let replay = host + .repository() + .authorize_challenge( + challenge_id, + connection.id(), + ping_operation, + policy_generation(7), + time(700), + ) + .await + .expect("restart replay"); + assert_eq!(replay, authorized); + host.close().await.expect("final close"); +} + +#[test] +fn connection_state_source_has_no_ambient_or_external_authority() { + for forbidden in [ + "rand::", + "getrandom", + "std::time", + "SystemTime", + "tokio::spawn", + "spawn_blocking", + "SqlitePool", + "SqliteConnection", + "nostr_sdk", + "reqwest", + "relay::", + "provider::", + ] { + assert!( + !CONNECTION_SOURCE.contains(forbidden), + "found forbidden connection-state authority `{forbidden}`" + ); + } + for required in [ + "ServiceSqliteTransaction", + "from_injected_entropy", + "policy_denied", + "authorization_challenge_expired", + "LIMIT 65", + ] { + assert!( + CONNECTION_SOURCE.contains(required), + "missing governed connection-state boundary `{required}`" + ); + } +} diff --git a/tests/services_hardening_signer_request_state.rs b/tests/services_hardening_signer_request_state.rs @@ -458,7 +458,7 @@ async fn concurrent_identical_admission_creates_one_request_and_bounded_replay_e } #[tokio::test] -async fn exact_schema_v2_state_advances_to_v3_before_request_admission() { +async fn exact_schema_v3_state_advances_to_v4_before_request_admission() { let directory = tempfile::tempdir().expect("temporary root"); let runtime = runtime(directory.path()); prepare_state_directory(&runtime); @@ -500,18 +500,26 @@ async fn exact_schema_v2_state_advances_to_v3_before_request_admission() { "DROP TRIGGER radroots_service_metadata_guard_update", "DROP TRIGGER myc_state_metadata_no_update", "DROP TRIGGER schema_migrations_no_delete", - "DROP TRIGGER nip46_request_dedup_guard_update", - "DROP TRIGGER nip46_requests_no_update", - "DROP TABLE nip46_request_dedup", - "DROP TABLE nip46_requests", - "UPDATE radroots_service_metadata SET state_schema_version = 2 WHERE singleton = 1", - "UPDATE myc_state_metadata SET state_contract_version = 2 WHERE singleton = 1", - "DELETE FROM schema_migrations WHERE version = 3", + "DROP TRIGGER connection_auth_challenges_no_delete", + "DROP TRIGGER connection_auth_challenges_guard_update", + "DROP TRIGGER nip46_request_decisions_no_delete", + "DROP TRIGGER nip46_request_decisions_guard_update", + "DROP TRIGGER connection_permissions_no_delete", + "DROP TRIGGER connection_permissions_no_update", + "DROP TRIGGER connections_no_delete", + "DROP TRIGGER connections_guard_update", + "DROP TABLE nip46_request_decisions", + "DROP TABLE connection_auth_challenges", + "DROP TABLE connection_permissions", + "DROP TABLE connections", + "UPDATE radroots_service_metadata SET state_schema_version = 3 WHERE singleton = 1", + "UPDATE myc_state_metadata SET state_contract_version = 3 WHERE singleton = 1", + "DELETE FROM schema_migrations WHERE version = 4", ] { sqlx::query(sql) .execute(&mut connection) .await - .expect("construct exact schema-v2 fixture"); + .expect("construct exact schema-v3 fixture"); } for sql in [&shared_update, &myc_update, &migration_delete] { sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str())) @@ -523,14 +531,14 @@ async fn exact_schema_v2_state_advances_to_v3_before_request_admission() { let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) .await - .expect("schema-v2 upgrade"); + .expect("schema-v3 upgrade"); let admitted = host .repository() .admit_signer_request(&request( - "request-after-v2", + "request-after-v3", 0x66, MycSignerRequestMethod::Ping, - b"after-v2", + b"after-v3", 0x66, 300, )) diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -8,8 +8,10 @@ use myc::{ MYC_STATE_SCHEMA_VERSION_1_SHA256, MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_2_SHA256, MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_3_OBJECT_COUNT, - MYC_STATE_SCHEMA_VERSION_3_SHA256, MycStateCatalogErrorKind, myc_migration_catalog, - myc_schema_catalog, validate_myc_state_catalogs, + MYC_STATE_SCHEMA_VERSION_3_SHA256, MYC_STATE_SCHEMA_VERSION_4_MIGRATION_SHA256, + MYC_STATE_SCHEMA_VERSION_4_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_4_SHA256, + MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, + validate_myc_state_catalogs, }; use radroots_service_sqlite::{ MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest, @@ -21,13 +23,13 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_through_v3_and_both_migrations_have_exact_literal_identities() { +fn schema_v1_through_v4_and_all_migrations_have_exact_literal_identities() { let migrations = myc_migration_catalog().expect("Myc migration catalog"); let schema = myc_schema_catalog().expect("Myc schema catalog"); assert_eq!(MYC_STATE_BASE_SCHEMA_VERSION, 1); - assert_eq!(MYC_STATE_SCHEMA_VERSION, 3); - assert_eq!(migrations.descriptors().len(), 2); + assert_eq!(MYC_STATE_SCHEMA_VERSION, 4); + assert_eq!(migrations.descriptors().len(), 3); let metadata = &migrations.descriptors()[0]; assert_eq!(metadata.target_version(), 2); assert_eq!(metadata.name().as_str(), "create_myc_state_metadata"); @@ -42,13 +44,23 @@ fn schema_v1_through_v3_and_both_migrations_have_exact_literal_identities() { request.checksum().as_bytes(), &MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256 ); - assert_eq!(migrations.current_version(), 3); + let connection = &migrations.descriptors()[2]; + assert_eq!(connection.target_version(), 4); + assert_eq!( + connection.name().as_str(), + "create_connection_authorization_state" + ); + assert_eq!( + connection.checksum().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_4_MIGRATION_SHA256 + ); + assert_eq!(migrations.current_version(), 4); assert_eq!( migrations.digest().as_bytes(), &MYC_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 3); + assert_eq!(schema.versions().len(), 4); assert_eq!(schema.versions()[0].version(), 1); assert_eq!( schema.versions()[0].object_count(), @@ -78,6 +90,16 @@ fn schema_v1_through_v3_and_both_migrations_have_exact_literal_identities() { schema.versions()[2].digest().as_bytes(), &MYC_STATE_SCHEMA_VERSION_3_SHA256 ); + assert_eq!(schema.versions()[3].version(), 4); + assert_eq!( + schema.versions()[3].object_count(), + MYC_STATE_SCHEMA_VERSION_4_OBJECT_COUNT + ); + assert_eq!(schema.versions()[3].object_count(), 25); + assert_eq!( + schema.versions()[3].digest().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_4_SHA256 + ); assert_eq!(schema.digest().as_bytes(), &MYC_STATE_SCHEMA_CATALOG_SHA256); assert_eq!(schema.migration_catalog_digest(), migrations.digest()); validate_myc_state_catalogs(&migrations, &schema).expect("exact catalogs"); @@ -88,7 +110,7 @@ fn schema_v1_through_v3_and_both_migrations_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_MIGRATION_CATALOG_SHA256), - "3d79b719ea3fe463e266f5ed0d1f091e3c33177c17820d21bd8596dfbd31aa9e" + "453d99f4c19c094c592f1a3fe7e28e82c1dea674514a9e7d063bc466c20bd4bd" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256), @@ -100,7 +122,7 @@ fn schema_v1_through_v3_and_both_migrations_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256), - "265564d09567724fac621d1e00c37dbccbe3cc24a9fab00e59ae70c6fe89872b" + "a47d9f0af804202bfa07268e08fb484ded590b785cc42a01094d1a8b9f37eeca" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256), @@ -110,6 +132,14 @@ fn schema_v1_through_v3_and_both_migrations_have_exact_literal_identities() { hex::encode(MYC_STATE_SCHEMA_VERSION_3_SHA256), "572fe6a4d36c0476ec40536f48028e1558488fb8abeba0a34ba69b4b7080ba08" ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_4_MIGRATION_SHA256), + "939c0ed07cd15cc0c794bf6c2af7192261409305c2876f3be363e8e4fe4a1abb" + ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_4_SHA256), + "479863d37d91e6c269fa3573db6c2e767cdd3b24a93ab482d774bcec0218c174" + ); } #[test] @@ -150,9 +180,12 @@ fn independent_validator_rejects_migration_or_schema_drift() { let v2 = SchemaVersionCatalog::new(2, [object.clone()], snapshot_digest).expect("schema v2"); let v3_digest = SchemaVersionCatalog::computed_digest(3, [object.clone()]).expect("schema-v3 digest"); - let v3 = SchemaVersionCatalog::new(3, [object], v3_digest).expect("schema v3"); + let v3 = SchemaVersionCatalog::new(3, [object.clone()], v3_digest).expect("schema v3"); + let v4_digest = + SchemaVersionCatalog::computed_digest(4, [object.clone()]).expect("schema-v4 digest"); + let v4 = SchemaVersionCatalog::new(4, [object], v4_digest).expect("schema v4"); let schema = - SchemaCatalog::new(&expected_migrations, [v1, v2, v3]).expect("drift schema catalog"); + SchemaCatalog::new(&expected_migrations, [v1, v2, v3, v4]).expect("drift schema catalog"); assert_eq!( validate_myc_state_catalogs(&expected_migrations, &schema) .expect_err("schema drift") diff --git a/tests/services_hardening_state_repository.rs b/tests/services_hardening_state_repository.rs @@ -119,7 +119,7 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { .fetch_all(&mut connection) .await .expect("migration rows"); - assert_eq!(migrations.len(), 2); + assert_eq!(migrations.len(), 3); assert_eq!(migrations[0].get::<i64, _>(0), 2); assert_eq!( migrations[0].get::<String, _>(1), @@ -130,6 +130,11 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { migrations[1].get::<String, _>(1), "create_nip46_request_admission" ); + assert_eq!(migrations[2].get::<i64, _>(0), 4); + assert_eq!( + migrations[2].get::<String, _>(1), + "create_connection_authorization_state" + ); let binding = sqlx::query( "SELECT normalized_config_sha256, transport_public_key, user_public_key, \ discovery_public_key, config_contract_version, state_contract_version, \ @@ -160,7 +165,10 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { .as_hex() ); assert_eq!(binding.get::<i64, _>(4), 1); - assert_eq!(binding.get::<i64, _>(5), 3); + assert_eq!( + binding.get::<i64, _>(5), + i64::from(MYC_STATE_SCHEMA_VERSION) + ); assert_eq!(binding.get::<i64, _>(6), 1); assert_eq!(binding.get::<i64, _>(7), 1); connection.close().await.expect("test connection close"); diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs @@ -11,11 +11,11 @@ use std::{ }; use myc::{ - MycConfigProfile, MycStateHostErrorKind, MycStateMaintenanceErrorKind, MycStateMetadata, - RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, finalize_myc_state_restore, - initialize_myc_state, open_myc_state_inspection, open_myc_state_read_write, - parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context, - stage_myc_state_restore, verify_myc_state_backup, + MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycStateHostErrorKind, + MycStateMaintenanceErrorKind, MycStateMetadata, RadrootsHostEnvironment, RadrootsPathResolver, + RadrootsPlatform, finalize_myc_state_restore, initialize_myc_state, open_myc_state_inspection, + open_myc_state_read_write, parse_myc_cli_v1_from, parse_myc_config_v1, + resolve_myc_runtime_context, stage_myc_state_restore, verify_myc_state_backup, }; use radroots_service_sqlite::{ BackupCreatedAtUnixMs, IntegrityCheckOutcome, IntegrityCheckedAtUnixMs, @@ -175,7 +175,10 @@ async fn backup_integrity_and_offline_restore_obey_one_exact_myc_authority() { .expect("online backup"); assert_eq!(manifest.service().as_str(), "myc"); assert_eq!(manifest.instance().as_str(), "primary"); - assert_eq!(manifest.state_schema_version().get(), 3); + assert_eq!( + manifest.state_schema_version().get(), + MYC_STATE_SCHEMA_VERSION + ); assert!(!manifest.protected_material_included()); let members = fs::read_dir(&bundle) .expect("backup directory") @@ -281,7 +284,10 @@ async fn backup_integrity_and_offline_restore_obey_one_exact_myc_authority() { format!("{verified:?}"), "MycVerifiedStateBackup([redacted])" ); - assert_eq!(verified.database_metadata().state_schema_version().get(), 3); + assert_eq!( + verified.database_metadata().state_schema_version().get(), + MYC_STATE_SCHEMA_VERSION + ); let staged = stage_myc_state_restore(&runtime, &metadata, verified) .await .expect("offline staging");