commit 9368d381f9b470e855396a586e078e8ff33d497d
parent e0a68ea7a9c778e6eece540ed1053a2406b436d3
Author: triesap <tyson@radroots.org>
Date: Fri, 21 Aug 2026 16:38:46 +0000
state: add connection authorization state
Diffstat:
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");