myc

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

commit e0a68ea7a9c778e6eece540ed1053a2406b436d3
parent c4e7d8624e927bc7a4f7699755a0e74c0881b9b6
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 15:52:22 +0000

state: persist idempotent signer requests

- add sealed bounded NIP-46 request and admission models
- bind stable operation identities to injected entropy and durable replay
- migrate the exact Myc catalog to schema version 3
- verify conflict, concurrency, restart, tamper, and redaction behavior

Diffstat:
MREADME | 32++++++++++++++++++++++----------
Msrc/lib.rs | 13+++++++++++--
Msrc/state_catalog.rs | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/state_host.rs | 4++--
Msrc/state_repository.rs | 24+++++++++++++++++++++---
Asrc/state_request.rs | 1040+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/services_hardening_signer_request_state.rs | 646+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_state_catalog.rs | 60+++++++++++++++++++++++++++++++++++++++++++++---------------
Mtests/services_hardening_state_repository.rs | 21+++++++++++++++------
Mtests/services_hardening_state_resilience.rs | 4++--
10 files changed, 2020 insertions(+), 55 deletions(-)

diff --git a/README b/README @@ -47,18 +47,30 @@ 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 -migration, 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-to-v2 prefix; read-only inspection requires the current catalog and exact -immutable Myc binding. +and schema-v3 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 +catalog and exact immutable Myc binding. The public Myc repository exposes no raw pool, connection, transaction-control -handle, path, or SQL. Its only SQLite mutation executes inside the shared -`ServiceSqliteTransaction` runner. Provider and relay work cannot occur inside -that transaction boundary. The v2 binding table and its update/delete guards -are checksum-pinned service-owned schema objects; later workflow tables remain -owned by their ordered repository steps. +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 +schema objects; later workflow tables remain owned by their ordered repository +steps. + +Signer-request admission validates bounded client, request, event, method, +canonical request, injected operation entropy, and injected time evidence +before storage. Stable domain-separated operation and correlation identities +bind the logical client request to its persisted entropy evidence. Event +identity and logical request identity retain distinct durable deduplication +records, so an exact replay is idempotent while event or request reuse with +different normalized content records a conflict without retaining the +decrypted request bytes. Replay and conflict counters are bounded and +survive explicit close and reopen; retention remains owned by its later state +checkpoint. Writable hosts expose Myc-bound online-backup and active-integrity operations. Backup verification retains the exact admitted member inode, and diff --git a/src/lib.rs b/src/lib.rs @@ -30,6 +30,7 @@ mod state_host; mod state_maintenance; mod state_metadata; mod state_repository; +mod state_request; pub mod transport; pub use app::{ @@ -114,8 +115,9 @@ pub use state_catalog::{ MYC_STATE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, 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, - MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, - validate_myc_state_catalogs, + 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, }; pub use state_host::{ MycStateHost, MycStateHostError, MycStateHostErrorKind, MycStateHostMode, initialize_myc_state, @@ -134,4 +136,11 @@ pub use state_metadata::{ pub use state_repository::{ MycStateRepository, MycStateRepositoryError, MycStateRepositoryErrorKind, }; +pub use state_request::{ + MYC_NIP46_CANONICAL_REQUEST_MAX_BYTES, MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES, + MycNip46ClientPublicKey, MycNip46EventId, MycNip46RequestId, MycRequestReceivedAtUnixMs, + MycSignerCorrelationId, MycSignerOperationId, MycSignerOperationNonce, MycSignerRequest, + MycSignerRequestAdmission, MycSignerRequestDigest, MycSignerRequestError, + MycSignerRequestErrorKind, MycSignerRequestMethod, MycSignerRequestRecord, +}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; 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 = 2; +pub const MYC_STATE_SCHEMA_VERSION: u32 = 3; /// The shared metadata and migration-ledger objects present at schema v1. pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; @@ -20,6 +20,9 @@ pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; /// The shared objects plus the three immutable Myc metadata objects at schema v2. 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; + /// 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, @@ -34,14 +37,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] = [ - 0xbe, 0x15, 0x58, 0x4e, 0x4e, 0x6f, 0xe1, 0xf5, 0xb8, 0x02, 0x09, 0xe8, 0xf6, 0x12, 0x5e, 0xcc, - 0x92, 0x81, 0xfc, 0x22, 0xe9, 0x76, 0x9a, 0x79, 0xf2, 0x10, 0xc3, 0x0c, 0x43, 0x1f, 0xf4, 0x62, + 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, ]; /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x67, 0x3f, 0x8b, 0xa2, 0x09, 0x5e, 0xe0, 0x2e, 0x80, 0x48, 0xaf, 0x85, 0x0d, 0x29, 0x44, 0x36, - 0xcb, 0x7b, 0x81, 0x50, 0xe9, 0x16, 0x93, 0xb7, 0x72, 0x7f, 0xae, 0x05, 0x81, 0x2e, 0x83, 0x1c, + 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, ]; /// SHA-256 identity of the schema-v2 migration content. @@ -50,6 +53,18 @@ pub const MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32] = [ 0xcb, 0x28, 0x57, 0xd2, 0x6a, 0x9d, 0xce, 0x74, 0x96, 0x1f, 0x4b, 0x78, 0x1e, 0x71, 0x00, 0x37, ]; +/// SHA-256 identity of the schema-v3 migration content. +pub const MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256: [u8; 32] = [ + 0x75, 0x31, 0x65, 0x13, 0x6b, 0x3d, 0xac, 0xe0, 0x09, 0x1d, 0x78, 0x2f, 0x33, 0xf6, 0xb1, 0x0c, + 0xa1, 0xa2, 0x82, 0x33, 0x14, 0x15, 0x8d, 0x80, 0xa4, 0x77, 0x5e, 0x0a, 0xf6, 0x28, 0xed, 0xf9, +]; + +/// SHA-256 identity of the schema-v3 object snapshot. +pub const MYC_STATE_SCHEMA_VERSION_3_SHA256: [u8; 32] = [ + 0x57, 0x2f, 0xe6, 0xa4, 0xd3, 0x6c, 0x04, 0x76, 0xec, 0x40, 0x53, 0x6f, 0x48, 0x02, 0x8e, 0x15, + 0x58, 0x48, 0x8f, 0xb8, 0xab, 0xeb, 0xa0, 0xa3, 0x4b, 0xa6, 0x9b, 0x4b, 0x70, 0x80, 0xba, 0x08, +]; + /// 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, @@ -68,6 +83,23 @@ const MYC_STATE_METADATA_NO_DELETE_SHA256: [u8; 32] = [ 0xa3, 0xcc, 0xf2, 0x81, 0xc3, 0x5b, 0x14, 0xa0, 0x24, 0xa7, 0x74, 0xa5, 0x67, 0x50, 0x3d, 0x4c, ]; +const NIP46_REQUESTS_TABLE_SHA256: [u8; 32] = [ + 0x7a, 0x62, 0x77, 0xaa, 0xa9, 0x71, 0x62, 0x2c, 0x1c, 0x7e, 0x0c, 0x0f, 0xab, 0x62, 0xe7, 0xfc, + 0xfa, 0xea, 0x1b, 0x1d, 0x6f, 0x20, 0xda, 0x14, 0x7a, 0x93, 0xc7, 0x80, 0x6d, 0xd4, 0xaa, 0x54, +]; +const NIP46_REQUEST_DEDUP_TABLE_SHA256: [u8; 32] = [ + 0x1d, 0xe1, 0x60, 0xcb, 0x35, 0xd1, 0x84, 0x66, 0x60, 0xda, 0xfc, 0xa4, 0xae, 0x26, 0x51, 0x12, + 0xd1, 0x4a, 0xa9, 0x19, 0x7e, 0xf3, 0x7f, 0x2a, 0x5a, 0xd7, 0xdf, 0x3d, 0xfe, 0x36, 0xd7, 0x65, +]; +const NIP46_REQUESTS_NO_UPDATE_SHA256: [u8; 32] = [ + 0x26, 0xfb, 0x6f, 0x52, 0x78, 0x7e, 0x07, 0x8a, 0x4a, 0xb9, 0x2f, 0xa1, 0x19, 0xf4, 0x65, 0x42, + 0x18, 0xea, 0x3d, 0x61, 0xad, 0x58, 0xb2, 0x01, 0x3a, 0x99, 0x0e, 0xbc, 0xc9, 0xd7, 0x6b, 0x35, +]; +const NIP46_REQUEST_DEDUP_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0x47, 0x57, 0x86, 0x7c, 0x88, 0xe8, 0xec, 0x05, 0x0c, 0xa5, 0x3d, 0x64, 0x79, 0xae, 0x22, 0xb4, + 0x9a, 0x11, 0xa4, 0xff, 0x39, 0x32, 0xd9, 0xa3, 0x88, 0x80, 0xd3, 0x1d, 0x7e, 0x7a, 0x94, 0x6c, +]; + macro_rules! myc_state_metadata_table_sql { () => { r#"CREATE TABLE myc_state_metadata ( @@ -128,6 +160,115 @@ const CREATE_MYC_STATE_METADATA_MIGRATION_SQL: &str = concat!( myc_state_metadata_no_delete_sql!(), ); +macro_rules! nip46_requests_table_sql { + () => { + r#"CREATE TABLE nip46_requests ( + operation_id BLOB NOT NULL PRIMARY KEY CHECK (length(operation_id) = 32), + correlation_id BLOB NOT NULL UNIQUE CHECK (length(correlation_id) = 32), + operation_nonce BLOB NOT NULL CHECK (length(operation_nonce) = 32), + request_identity_sha256 BLOB NOT NULL UNIQUE CHECK (length(request_identity_sha256) = 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]*'), + request_id TEXT NOT NULL + CHECK (length(CAST(request_id AS BLOB)) BETWEEN 1 AND 128), + first_event_id BLOB NOT NULL CHECK (length(first_event_id) = 32), + method TEXT NOT NULL CHECK (method IN ( + 'connect', + 'get_public_key', + 'get_session_capability', + 'sign_event', + 'nip04_encrypt', + 'nip04_decrypt', + 'nip44_encrypt', + 'nip44_decrypt', + 'ping', + 'switch_relays', + 'logout' + )), + request_sha256 BLOB NOT NULL CHECK (length(request_sha256) = 32), + received_at_unix_ms INTEGER NOT NULL + CHECK (received_at_unix_ms BETWEEN 1 AND 9223372036854775807) +) STRICT"# + }; +} + +macro_rules! nip46_request_dedup_table_sql { + () => { + r#"CREATE TABLE nip46_request_dedup ( + dedup_kind TEXT NOT NULL CHECK (dedup_kind IN ('request', 'event')), + identity_sha256 BLOB NOT NULL CHECK (length(identity_sha256) = 32), + request_sha256 BLOB NOT NULL CHECK (length(request_sha256) = 32), + operation_id BLOB NOT NULL CHECK (length(operation_id) = 32) + REFERENCES nip46_requests(operation_id), + replay_count INTEGER NOT NULL CHECK (replay_count BETWEEN 0 AND 9223372036854775807), + conflict_count INTEGER NOT NULL CHECK (conflict_count BETWEEN 0 AND 9223372036854775807), + first_seen_at_unix_ms INTEGER NOT NULL + CHECK (first_seen_at_unix_ms BETWEEN 1 AND 9223372036854775807), + last_seen_at_unix_ms INTEGER NOT NULL + CHECK (last_seen_at_unix_ms BETWEEN first_seen_at_unix_ms AND 9223372036854775807), + PRIMARY KEY (dedup_kind, identity_sha256) +) STRICT"# + }; +} + +macro_rules! nip46_requests_no_update_sql { + () => { + r#"CREATE TRIGGER nip46_requests_no_update +BEFORE UPDATE ON nip46_requests +BEGIN + SELECT RAISE(ABORT, 'NIP-46 request identity is immutable'); +END"# + }; +} + +macro_rules! nip46_request_dedup_guard_update_sql { + () => { + r#"CREATE TRIGGER nip46_request_dedup_guard_update +BEFORE UPDATE ON nip46_request_dedup +WHEN NEW.dedup_kind != OLD.dedup_kind + OR NEW.identity_sha256 != OLD.identity_sha256 + OR NEW.request_sha256 != OLD.request_sha256 + OR NEW.operation_id != OLD.operation_id + OR NEW.first_seen_at_unix_ms != OLD.first_seen_at_unix_ms + OR NEW.last_seen_at_unix_ms < OLD.last_seen_at_unix_ms + OR NOT ( + ( + OLD.replay_count < 9223372036854775807 + AND NEW.replay_count = OLD.replay_count + 1 + AND NEW.conflict_count = OLD.conflict_count + ) OR ( + OLD.conflict_count < 9223372036854775807 + AND NEW.conflict_count = OLD.conflict_count + 1 + AND NEW.replay_count = OLD.replay_count + ) + ) +BEGIN + SELECT RAISE(ABORT, 'NIP-46 request evidence is append-only'); +END"# + }; +} + +const CREATE_NIP46_REQUESTS_TABLE_SQL: &str = nip46_requests_table_sql!(); +const CREATE_NIP46_REQUEST_DEDUP_TABLE_SQL: &str = nip46_request_dedup_table_sql!(); +const CREATE_NIP46_REQUESTS_NO_UPDATE_SQL: &str = nip46_requests_no_update_sql!(); +const CREATE_NIP46_REQUEST_DEDUP_GUARD_UPDATE_SQL: &str = nip46_request_dedup_guard_update_sql!(); + +const CREATE_NIP46_REQUEST_ADMISSION_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 = 2 THEN 3 ELSE 0 END WHERE singleton = 1;\n", + myc_state_metadata_no_update_sql!(), + ";\n", + nip46_requests_table_sql!(), + ";\n", + nip46_request_dedup_table_sql!(), + ";\n", + nip46_requests_no_update_sql!(), + ";\n", + nip46_request_dedup_guard_update_sql!(), +); + /// Stable classes for invalid embedded Myc catalog definitions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MycStateCatalogErrorKind { @@ -199,17 +340,24 @@ impl Error for MycStateCatalogError {} /// Constructs the exact ordered Myc migration catalog. pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> { - let migration = MigrationDescriptor::sql( - MYC_STATE_SCHEMA_VERSION, + let metadata = MigrationDescriptor::sql( + 2, "create_myc_state_metadata", CREATE_MYC_STATE_METADATA_MIGRATION_SQL, MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; - let catalog = MigrationCatalog::new([migration]) + let requests = MigrationDescriptor::sql( + 3, + "create_nip46_request_admission", + CREATE_NIP46_REQUEST_ADMISSION_MIGRATION_SQL, + MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; + let catalog = MigrationCatalog::new([metadata, requests]) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; if catalog.current_version() != MYC_STATE_SCHEMA_VERSION - || catalog.descriptors().len() != 1 + || catalog.descriptors().len() != 2 || catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256 { return Err(MycStateCatalogError::new( @@ -229,12 +377,18 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; let version_two = SchemaVersionCatalog::new( - MYC_STATE_SCHEMA_VERSION, + 2, myc_state_metadata_objects()?, SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_2_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; - let catalog = SchemaCatalog::new(&migrations, [version_one, version_two]) + let version_three = SchemaVersionCatalog::new( + 3, + myc_state_request_objects()?, + 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))?; validate_myc_state_catalogs(&migrations, &catalog)?; Ok(catalog) @@ -268,6 +422,47 @@ fn myc_state_metadata_objects() -> Result<[SchemaObject; 3], MycStateCatalogErro Ok([table, update, delete]) } +fn myc_state_request_objects() -> Result<[SchemaObject; 7], MycStateCatalogError> { + let [metadata_table, metadata_update, metadata_delete] = myc_state_metadata_objects()?; + Ok([ + metadata_table, + metadata_update, + metadata_delete, + SchemaObject::new( + SchemaObjectKind::Table, + "nip46_requests", + "nip46_requests", + CREATE_NIP46_REQUESTS_TABLE_SQL, + SchemaDigest::from_bytes(NIP46_REQUESTS_TABLE_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?, + SchemaObject::new( + SchemaObjectKind::Table, + "nip46_request_dedup", + "nip46_request_dedup", + CREATE_NIP46_REQUEST_DEDUP_TABLE_SQL, + SchemaDigest::from_bytes(NIP46_REQUEST_DEDUP_TABLE_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?, + SchemaObject::new( + SchemaObjectKind::Trigger, + "nip46_requests_no_update", + "nip46_requests", + CREATE_NIP46_REQUESTS_NO_UPDATE_SQL, + SchemaDigest::from_bytes(NIP46_REQUESTS_NO_UPDATE_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?, + SchemaObject::new( + SchemaObjectKind::Trigger, + "nip46_request_dedup_guard_update", + "nip46_request_dedup", + CREATE_NIP46_REQUEST_DEDUP_GUARD_UPDATE_SQL, + SchemaDigest::from_bytes(NIP46_REQUEST_DEDUP_GUARD_UPDATE_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?, + ]) +} + /// Independently validates exact catalog versions, counts, and digests. pub fn validate_myc_state_catalogs( migrations: &MigrationCatalog, @@ -276,19 +471,25 @@ 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() == 1 - && descriptors[0].target_version() == MYC_STATE_SCHEMA_VERSION + && descriptors.len() == 2 + && 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 && migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 2 + && versions.len() == 3 && 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 - && versions[1].version() == MYC_STATE_SCHEMA_VERSION + && versions[1].version() == 2 && versions[1].object_count() == MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT && versions[1].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_SHA256 + && 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 && schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256; if valid { Ok(()) 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() == 1 + && outcome.applied_count() == 2 } 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, 1) | (MYC_STATE_SCHEMA_VERSION, 0) + (MYC_STATE_BASE_SCHEMA_VERSION, 2) | (2, 1) | (MYC_STATE_SCHEMA_VERSION, 0) ) } diff --git a/src/state_repository.rs b/src/state_repository.rs @@ -84,7 +84,7 @@ pub struct MycStateRepositoryError { } impl MycStateRepositoryError { - const fn new(kind: MycStateRepositoryErrorKind) -> Self { + pub(crate) const fn new(kind: MycStateRepositoryErrorKind) -> Self { Self { kind } } @@ -146,6 +146,14 @@ impl<'host> MycStateRepository<'host> { Self { host, expected } } + pub(crate) const fn host(&self) -> &'host ServiceSqliteHost { + self.host + } + + pub(crate) const fn expected(&self) -> &'host MycStateMetadata { + self.expected + } + /// Re-verifies the immutable Myc binding through the sealed transaction executor. pub async fn verify_binding(&self) -> Result<(), MycStateRepositoryError> { self.transact(false).await @@ -190,7 +198,7 @@ impl fmt::Debug for MycStateRepository<'_> { } #[derive(Clone, PartialEq, Eq)] -struct PersistedMetadata { +pub(crate) struct PersistedMetadata { normalized_config_sha256: [u8; 32], transport_public_key: Box<str>, user_public_key: Box<str>, @@ -219,11 +227,21 @@ impl From<&MycStateMetadata> for PersistedMetadata { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RepositoryOperationError { +pub(crate) enum RepositoryOperationError { Binding, Storage, } +pub(crate) async fn require_expected_metadata( + transaction: &mut ServiceSqliteTransaction<'_>, + expected: &PersistedMetadata, +) -> Result<(), RepositoryOperationError> { + match read_metadata(transaction).await? { + Some(actual) if actual == *expected => Ok(()), + Some(_) | None => Err(RepositoryOperationError::Binding), + } +} + async fn read_metadata( transaction: &mut ServiceSqliteTransaction<'_>, ) -> Result<Option<PersistedMetadata>, RepositoryOperationError> { diff --git a/src/state_request.rs b/src/state_request.rs @@ -0,0 +1,1040 @@ +//! Durable NIP-46 request identity and idempotent admission. + +use core::fmt; +use std::error::Error; + +use nostr::PublicKey; +use radroots_service_sqlite::{ + ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, +}; +use sha2::{Digest, Sha256}; +use sqlx::Row; + +use crate::{ + MycStateRepository, MycStateRepositoryError, MycStateRepositoryErrorKind, + state_repository::{PersistedMetadata, RepositoryOperationError, require_expected_metadata}, +}; + +/// Maximum UTF-8 byte length of a canonical NIP-46 request identifier. +pub const MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES: usize = 128; + +/// Maximum canonical decrypted request bytes accepted by the state boundary. +pub const MYC_NIP46_CANONICAL_REQUEST_MAX_BYTES: usize = 262_144; + +const REQUEST_DIGEST_DOMAIN: &[u8] = b"radroots.myc.nip46.request.v1\0"; +const REQUEST_IDENTITY_DOMAIN: &[u8] = b"radroots.myc.nip46.request_identity.v1\0"; +const OPERATION_ID_DOMAIN: &[u8] = b"radroots.myc.nip46.operation.v1\0"; +const CORRELATION_ID_DOMAIN: &[u8] = b"radroots.myc.nip46.correlation.v1\0"; + +const READ_BY_DEDUP_SQL: &str = r#"SELECT + CASE WHEN typeof(r.operation_id) = 'blob' AND length(r.operation_id) = 32 + THEN r.operation_id ELSE NULL END AS operation_id, + CASE WHEN typeof(r.correlation_id) = 'blob' AND length(r.correlation_id) = 32 + THEN r.correlation_id ELSE NULL END AS correlation_id, + CASE WHEN typeof(r.operation_nonce) = 'blob' AND length(r.operation_nonce) = 32 + THEN r.operation_nonce ELSE NULL END AS operation_nonce, + CASE + WHEN typeof(r.request_identity_sha256) = 'blob' + AND length(r.request_identity_sha256) = 32 + THEN r.request_identity_sha256 + ELSE NULL + END AS request_identity_sha256, + CASE + WHEN typeof(selected.request_sha256) = 'blob' + AND length(selected.request_sha256) = 32 + THEN selected.request_sha256 + ELSE NULL + END AS selected_request_sha256, + CASE + WHEN typeof(request_dedup.request_sha256) = 'blob' + AND length(request_dedup.request_sha256) = 32 + THEN request_dedup.request_sha256 + ELSE NULL + END AS logical_request_sha256, + CASE + WHEN typeof(request_dedup.operation_id) = 'blob' + AND length(request_dedup.operation_id) = 32 + THEN request_dedup.operation_id + ELSE NULL + END AS logical_operation_id, + CASE + WHEN typeof(r.client_public_key) = 'text' + AND length(CAST(r.client_public_key AS BLOB)) = 64 + THEN r.client_public_key + ELSE NULL + END AS client_public_key, + CASE + WHEN typeof(r.request_id) = 'text' + AND length(CAST(r.request_id AS BLOB)) BETWEEN 1 AND 128 + THEN r.request_id + ELSE NULL + END AS request_id, + CASE WHEN typeof(r.first_event_id) = 'blob' AND length(r.first_event_id) = 32 + THEN r.first_event_id ELSE NULL END AS first_event_id, + CASE + WHEN typeof(r.method) = 'text' + AND length(CAST(r.method AS BLOB)) BETWEEN 1 AND 64 + THEN r.method + ELSE NULL + END AS method, + CASE WHEN typeof(r.request_sha256) = 'blob' AND length(r.request_sha256) = 32 + THEN r.request_sha256 ELSE NULL END AS request_sha256, + CASE + WHEN typeof(r.received_at_unix_ms) = 'integer' + AND r.received_at_unix_ms BETWEEN 1 AND 9223372036854775807 + THEN r.received_at_unix_ms + ELSE NULL + END AS received_at_unix_ms, + CASE + WHEN typeof(request_dedup.replay_count) = 'integer' + AND request_dedup.replay_count BETWEEN 0 AND 9223372036854775807 + THEN request_dedup.replay_count + ELSE NULL + END AS replay_count, + CASE + WHEN typeof(request_dedup.conflict_count) = 'integer' + AND request_dedup.conflict_count BETWEEN 0 AND 9223372036854775807 + THEN request_dedup.conflict_count + ELSE NULL + END AS conflict_count, + CASE + WHEN typeof(request_dedup.last_seen_at_unix_ms) = 'integer' + AND request_dedup.last_seen_at_unix_ms BETWEEN 1 AND 9223372036854775807 + THEN request_dedup.last_seen_at_unix_ms + ELSE NULL + END AS last_seen_at_unix_ms +FROM nip46_request_dedup AS selected +JOIN nip46_requests AS r ON r.operation_id = selected.operation_id +JOIN nip46_request_dedup AS request_dedup + ON request_dedup.dedup_kind = 'request' + AND request_dedup.identity_sha256 = r.request_identity_sha256 +WHERE selected.dedup_kind = ? AND selected.identity_sha256 = ? +LIMIT 2"#; + +const INSERT_REQUEST_SQL: &str = r#"INSERT INTO nip46_requests ( + operation_id, + correlation_id, + operation_nonce, + request_identity_sha256, + client_public_key, + request_id, + first_event_id, + method, + request_sha256, + received_at_unix_ms +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#; + +const INSERT_DEDUP_SQL: &str = r#"INSERT INTO nip46_request_dedup ( + dedup_kind, + identity_sha256, + request_sha256, + operation_id, + replay_count, + conflict_count, + first_seen_at_unix_ms, + last_seen_at_unix_ms +) VALUES (?, ?, ?, ?, ?, 0, ?, ?)"#; + +const RECORD_REPLAY_SQL: &str = r#"UPDATE nip46_request_dedup +SET replay_count = replay_count + 1, + last_seen_at_unix_ms = MAX(last_seen_at_unix_ms, ?) +WHERE dedup_kind = ? + AND identity_sha256 = ? + AND replay_count < 9223372036854775807"#; + +const RECORD_CONFLICT_SQL: &str = r#"UPDATE nip46_request_dedup +SET conflict_count = conflict_count + 1, + last_seen_at_unix_ms = MAX(last_seen_at_unix_ms, ?) +WHERE dedup_kind = ? + AND identity_sha256 = ? + AND conflict_count < 9223372036854775807"#; + +/// A bounded canonical NIP-46 request ID. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct MycNip46RequestId(Box<str>); + +impl MycNip46RequestId { + /// Validates borrowed input before allocating an owned identifier. + pub fn new(value: &str) -> Result<Self, MycSignerRequestError> { + if value.is_empty() + || value.len() > MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(MycSignerRequestError::new( + MycSignerRequestErrorKind::InvalidRequestId, + )); + } + Ok(Self(value.into())) + } + + /// Returns the exact canonical request ID. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for MycNip46RequestId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycNip46RequestId([redacted])") + } +} + +/// One validated NIP-46 client public key. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct MycNip46ClientPublicKey(Box<str>); + +impl MycNip46ClientPublicKey { + /// Parses one canonical lowercase 32-byte x-only public key. + pub fn new(value: &str) -> Result<Self, MycSignerRequestError> { + if value.len() != 64 + || value + .bytes() + .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase()) + { + return Err(MycSignerRequestError::new( + MycSignerRequestErrorKind::InvalidClientIdentity, + )); + } + let public_key = PublicKey::from_hex(value).map_err(|_| { + MycSignerRequestError::new(MycSignerRequestErrorKind::InvalidClientIdentity) + })?; + if public_key.to_hex() != value { + return Err(MycSignerRequestError::new( + MycSignerRequestErrorKind::InvalidClientIdentity, + )); + } + Ok(Self(value.into())) + } + + /// Returns the canonical public identity. + #[must_use] + pub fn as_hex(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for MycNip46ClientPublicKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycNip46ClientPublicKey([redacted])") + } +} + +macro_rules! redacted_digest { + ($name:ident) => { + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + pub struct $name([u8; 32]); + + impl $name { + /// Returns the exact 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(concat!(stringify!($name), "([redacted])")) + } + } + }; +} + +redacted_digest!(MycNip46EventId); +redacted_digest!(MycSignerRequestDigest); +redacted_digest!(MycSignerOperationId); +redacted_digest!(MycSignerCorrelationId); + +/// One-use entropy evidence for a new logical signer operation. +/// +/// The runtime obtains these bytes from its injected entropy source. Admission +/// consumes the value, binds it to the logical request identity, and persists +/// it so the resulting operation identity can be revalidated after restart. +#[derive(PartialEq, Eq)] +pub struct MycSignerOperationNonce([u8; 32]); + +impl MycSignerOperationNonce { + /// Wraps exact bytes supplied by the injected entropy boundary. + #[must_use] + pub const fn from_injected_entropy(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl fmt::Debug for MycSignerOperationNonce { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycSignerOperationNonce([redacted])") + } +} + +impl MycNip46EventId { + /// Constructs an event identity from an already verified NIP-01 event ID. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl MycSignerRequestDigest { + /// Hashes exact canonical decrypted request bytes under the Myc domain. + pub fn for_canonical_request(bytes: &[u8]) -> Result<Self, MycSignerRequestError> { + if bytes.is_empty() || bytes.len() > MYC_NIP46_CANONICAL_REQUEST_MAX_BYTES { + return Err(MycSignerRequestError::new( + MycSignerRequestErrorKind::InvalidCanonicalRequest, + )); + } + let mut hasher = Sha256::new(); + hasher.update(REQUEST_DIGEST_DOMAIN); + hasher.update( + u64::try_from(bytes.len()) + .expect("bounded request length fits u64") + .to_be_bytes(), + ); + hasher.update(bytes); + Ok(Self(hasher.finalize().into())) + } +} + +/// Exact supported NIP-46 method identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MycSignerRequestMethod { + Connect, + GetPublicKey, + GetSessionCapability, + SignEvent, + Nip04Encrypt, + Nip04Decrypt, + Nip44Encrypt, + Nip44Decrypt, + Ping, + SwitchRelays, + Logout, +} + +impl MycSignerRequestMethod { + /// Returns the canonical NIP-46 wire spelling. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Connect => "connect", + Self::GetPublicKey => "get_public_key", + Self::GetSessionCapability => "get_session_capability", + Self::SignEvent => "sign_event", + Self::Nip04Encrypt => "nip04_encrypt", + Self::Nip04Decrypt => "nip04_decrypt", + Self::Nip44Encrypt => "nip44_encrypt", + Self::Nip44Decrypt => "nip44_decrypt", + Self::Ping => "ping", + Self::SwitchRelays => "switch_relays", + Self::Logout => "logout", + } + } + + fn parse(value: &str) -> Option<Self> { + match value { + "connect" => Some(Self::Connect), + "get_public_key" => Some(Self::GetPublicKey), + "get_session_capability" => Some(Self::GetSessionCapability), + "sign_event" => Some(Self::SignEvent), + "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), + _ => None, + } + } +} + +/// Positive UTC millisecond instant at which a request reached admission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MycRequestReceivedAtUnixMs(u64); + +impl MycRequestReceivedAtUnixMs { + /// Validates a positive instant representable by SQLite's signed integer. + pub fn new(value: u64) -> Result<Self, MycSignerRequestError> { + if value == 0 || i64::try_from(value).is_err() { + return Err(MycSignerRequestError::new( + MycSignerRequestErrorKind::InvalidReceivedAt, + )); + } + Ok(Self(value)) + } + + #[must_use] + /// Returns the validated UTC millisecond instant. + pub const fn get(self) -> u64 { + self.0 + } + + fn sqlite_value(self) -> i64 { + i64::try_from(self.0).expect("validated request time fits SQLite integer") + } +} + +/// Fully validated request-admission input. +#[derive(PartialEq, Eq)] +pub struct MycSignerRequest { + client_public_key: MycNip46ClientPublicKey, + request_id: MycNip46RequestId, + event_id: MycNip46EventId, + method: MycSignerRequestMethod, + request_digest: MycSignerRequestDigest, + request_identity: [u8; 32], + operation_nonce: MycSignerOperationNonce, + received_at: MycRequestReceivedAtUnixMs, +} + +impl MycSignerRequest { + /// Binds validated input to one caller-supplied injected-entropy value. + #[must_use] + pub fn new( + client_public_key: MycNip46ClientPublicKey, + request_id: MycNip46RequestId, + event_id: MycNip46EventId, + method: MycSignerRequestMethod, + request_digest: MycSignerRequestDigest, + operation_nonce: MycSignerOperationNonce, + received_at: MycRequestReceivedAtUnixMs, + ) -> Self { + let request_identity = derive_request_identity(&client_public_key, &request_id); + Self { + client_public_key, + request_id, + event_id, + method, + request_digest, + request_identity, + operation_nonce, + received_at, + } + } + + fn owned(&self) -> Self { + Self { + client_public_key: self.client_public_key.clone(), + request_id: self.request_id.clone(), + event_id: self.event_id, + method: self.method, + request_digest: self.request_digest, + request_identity: self.request_identity, + operation_nonce: MycSignerOperationNonce(self.operation_nonce.0), + received_at: self.received_at, + } + } +} + +impl fmt::Debug for MycSignerRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycSignerRequest([redacted])") + } +} + +/// Durable state of one accepted logical NIP-46 request. +#[derive(Clone, PartialEq, Eq)] +pub struct MycSignerRequestRecord { + operation_id: MycSignerOperationId, + correlation_id: MycSignerCorrelationId, + client_public_key: MycNip46ClientPublicKey, + request_id: MycNip46RequestId, + first_event_id: MycNip46EventId, + method: MycSignerRequestMethod, + request_digest: MycSignerRequestDigest, + received_at: MycRequestReceivedAtUnixMs, + replay_count: u64, + conflict_count: u64, + last_seen_at: MycRequestReceivedAtUnixMs, +} + +impl MycSignerRequestRecord { + #[must_use] + /// Returns the stable logical operation identity. + pub const fn operation_id(&self) -> MycSignerOperationId { + self.operation_id + } + + #[must_use] + /// Returns the stable correlation identity. + pub const fn correlation_id(&self) -> MycSignerCorrelationId { + self.correlation_id + } + + #[must_use] + /// Returns the closed request method recorded for the operation. + pub const fn method(&self) -> MycSignerRequestMethod { + self.method + } + + #[must_use] + /// Returns the number of accepted exact replays. + pub const fn replay_count(&self) -> u64 { + self.replay_count + } + + #[must_use] + /// Returns the number of rejected conflicting reuses. + pub const fn conflict_count(&self) -> u64 { + self.conflict_count + } +} + +impl fmt::Debug for MycSignerRequestRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycSignerRequestRecord") + .field("method", &self.method) + .field("replay_count", &self.replay_count) + .field("conflict_count", &self.conflict_count) + .field("identity", &"[redacted]") + .finish() + } +} + +/// Idempotent result of durable request admission. +#[derive(Clone, PartialEq, Eq)] +pub enum MycSignerRequestAdmission { + Admitted(MycSignerRequestRecord), + ExactReplay(MycSignerRequestRecord), + ConflictingReuse(MycSignerRequestRecord), +} + +impl MycSignerRequestAdmission { + #[must_use] + /// Returns the durable record associated with the admission outcome. + pub const fn record(&self) -> &MycSignerRequestRecord { + match self { + Self::Admitted(record) | Self::ExactReplay(record) | Self::ConflictingReuse(record) => { + record + } + } + } +} + +impl fmt::Debug for MycSignerRequestAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Admitted(_) => "MycSignerRequestAdmission::Admitted([redacted])", + Self::ExactReplay(_) => "MycSignerRequestAdmission::ExactReplay([redacted])", + Self::ConflictingReuse(_) => "MycSignerRequestAdmission::ConflictingReuse([redacted])", + }) + } +} + +/// Stable source-free input failure for request construction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycSignerRequestErrorKind { + InvalidRequestId, + InvalidClientIdentity, + InvalidCanonicalRequest, + InvalidReceivedAt, +} + +impl MycSignerRequestErrorKind { + #[must_use] + /// Returns the stable machine-readable classification. + pub const fn code(self) -> &'static str { + match self { + Self::InvalidRequestId => "signer_request_id_invalid", + Self::InvalidClientIdentity => "signer_request_client_identity_invalid", + Self::InvalidCanonicalRequest => "signer_request_payload_invalid", + Self::InvalidReceivedAt => "signer_request_time_invalid", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycSignerRequestError { + kind: MycSignerRequestErrorKind, +} + +impl MycSignerRequestError { + const fn new(kind: MycSignerRequestErrorKind) -> Self { + Self { kind } + } + + #[must_use] + /// Returns the stable failure class. + pub const fn kind(self) -> MycSignerRequestErrorKind { + self.kind + } + + #[must_use] + /// Returns the stable machine-readable failure code. + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for MycSignerRequestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycSignerRequestErrorKind::InvalidRequestId => "NIP-46 request ID is invalid", + MycSignerRequestErrorKind::InvalidClientIdentity => "NIP-46 client identity is invalid", + MycSignerRequestErrorKind::InvalidCanonicalRequest => { + "canonical NIP-46 request is invalid" + } + MycSignerRequestErrorKind::InvalidReceivedAt => "NIP-46 request time is invalid", + }) + } +} + +impl fmt::Debug for MycSignerRequestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycSignerRequestError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycSignerRequestError {} + +impl MycStateRepository<'_> { + /// Atomically admits a new request, replays an exact request, or records conflict. + pub async fn admit_signer_request( + &self, + request: &MycSignerRequest, + ) -> Result<MycSignerRequestAdmission, MycStateRepositoryError> { + let request = request.owned(); + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + require_expected_metadata(transaction, &expected) + .await + .map_err(|error| match error { + RepositoryOperationError::Binding => RequestOperationError::Binding, + RepositoryOperationError::Storage => RequestOperationError::Storage, + })?; + admit_request(transaction, &request).await + }) + }) + .await + .map_err(map_transaction_error) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RequestOperationError { + Binding, + Storage, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DedupKind { + Request, + Event, +} + +impl DedupKind { + const fn as_str(self) -> &'static str { + match self { + Self::Request => "request", + Self::Event => "event", + } + } +} + +async fn admit_request( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycSignerRequest, +) -> Result<MycSignerRequestAdmission, RequestOperationError> { + let request_match = + read_by_dedup(transaction, DedupKind::Request, &request.request_identity).await?; + let event_match = + read_by_dedup(transaction, DedupKind::Event, request.event_id.as_bytes()).await?; + + match (request_match, event_match) { + (None, None) => insert_new_request(transaction, request).await, + (Some(existing), event) if exact_request(&existing, request) => { + if let Some(event) = event { + if event.operation_id != existing.operation_id { + record_conflict( + transaction, + DedupKind::Request, + &request.request_identity, + request.received_at, + ) + .await?; + return read_outcome( + transaction, + DedupKind::Request, + &request.request_identity, + MycSignerRequestAdmission::ConflictingReuse, + ) + .await; + } + record_replay( + transaction, + DedupKind::Event, + request.event_id.as_bytes(), + request.received_at, + ) + .await?; + } else { + insert_dedup( + transaction, + DedupKind::Event, + request.event_id.as_bytes(), + request.request_digest.as_bytes(), + existing.operation_id.as_bytes(), + 0, + request.received_at, + ) + .await?; + } + record_replay( + transaction, + DedupKind::Request, + &request.request_identity, + request.received_at, + ) + .await?; + read_outcome( + transaction, + DedupKind::Request, + &request.request_identity, + MycSignerRequestAdmission::ExactReplay, + ) + .await + } + (Some(_), _) => { + record_conflict( + transaction, + DedupKind::Request, + &request.request_identity, + request.received_at, + ) + .await?; + read_outcome( + transaction, + DedupKind::Request, + &request.request_identity, + MycSignerRequestAdmission::ConflictingReuse, + ) + .await + } + (None, Some(_)) => { + record_conflict( + transaction, + DedupKind::Event, + request.event_id.as_bytes(), + request.received_at, + ) + .await?; + let record = read_by_dedup(transaction, DedupKind::Event, request.event_id.as_bytes()) + .await? + .ok_or(RequestOperationError::Binding)?; + Ok(MycSignerRequestAdmission::ConflictingReuse(record)) + } + } +} + +async fn insert_new_request( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycSignerRequest, +) -> Result<MycSignerRequestAdmission, RequestOperationError> { + let operation_id = MycSignerOperationId(derive_operation_id( + &request.request_identity, + &request.operation_nonce, + )); + let correlation_id = MycSignerCorrelationId(derive_digest( + CORRELATION_ID_DOMAIN, + operation_id.as_bytes(), + )); + let result = sqlx::query(INSERT_REQUEST_SQL) + .bind(operation_id.as_bytes().as_slice()) + .bind(correlation_id.as_bytes().as_slice()) + .bind(request.operation_nonce.0.as_slice()) + .bind(request.request_identity.as_slice()) + .bind(request.client_public_key.as_hex()) + .bind(request.request_id.as_str()) + .bind(request.event_id.as_bytes().as_slice()) + .bind(request.method.as_str()) + .bind(request.request_digest.as_bytes().as_slice()) + .bind(request.received_at.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| RequestOperationError::Storage)?; + if result.rows_affected() != 1 { + return Err(RequestOperationError::Storage); + } + insert_dedup( + transaction, + DedupKind::Request, + &request.request_identity, + request.request_digest.as_bytes(), + operation_id.as_bytes(), + 0, + request.received_at, + ) + .await?; + insert_dedup( + transaction, + DedupKind::Event, + request.event_id.as_bytes(), + request.request_digest.as_bytes(), + operation_id.as_bytes(), + 0, + request.received_at, + ) + .await?; + read_outcome( + transaction, + DedupKind::Request, + &request.request_identity, + MycSignerRequestAdmission::Admitted, + ) + .await +} + +async fn insert_dedup( + transaction: &mut ServiceSqliteTransaction<'_>, + kind: DedupKind, + identity: &[u8; 32], + request_digest: &[u8; 32], + operation_id: &[u8; 32], + replay_count: i64, + received_at: MycRequestReceivedAtUnixMs, +) -> Result<(), RequestOperationError> { + let result = sqlx::query(INSERT_DEDUP_SQL) + .bind(kind.as_str()) + .bind(identity.as_slice()) + .bind(request_digest.as_slice()) + .bind(operation_id.as_slice()) + .bind(replay_count) + .bind(received_at.sqlite_value()) + .bind(received_at.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| RequestOperationError::Storage)?; + (result.rows_affected() == 1) + .then_some(()) + .ok_or(RequestOperationError::Storage) +} + +async fn record_replay( + transaction: &mut ServiceSqliteTransaction<'_>, + kind: DedupKind, + identity: &[u8; 32], + received_at: MycRequestReceivedAtUnixMs, +) -> Result<(), RequestOperationError> { + update_counter(transaction, RECORD_REPLAY_SQL, kind, identity, received_at).await +} + +async fn record_conflict( + transaction: &mut ServiceSqliteTransaction<'_>, + kind: DedupKind, + identity: &[u8; 32], + received_at: MycRequestReceivedAtUnixMs, +) -> Result<(), RequestOperationError> { + update_counter( + transaction, + RECORD_CONFLICT_SQL, + kind, + identity, + received_at, + ) + .await +} + +async fn update_counter( + transaction: &mut ServiceSqliteTransaction<'_>, + sql: &'static str, + kind: DedupKind, + identity: &[u8; 32], + received_at: MycRequestReceivedAtUnixMs, +) -> Result<(), RequestOperationError> { + let result = sqlx::query(sql) + .bind(received_at.sqlite_value()) + .bind(kind.as_str()) + .bind(identity.as_slice()) + .execute(&mut *transaction) + .await + .map_err(|_| RequestOperationError::Storage)?; + (result.rows_affected() == 1) + .then_some(()) + .ok_or(RequestOperationError::Storage) +} + +async fn read_outcome( + transaction: &mut ServiceSqliteTransaction<'_>, + kind: DedupKind, + identity: &[u8; 32], + outcome: fn(MycSignerRequestRecord) -> MycSignerRequestAdmission, +) -> Result<MycSignerRequestAdmission, RequestOperationError> { + read_by_dedup(transaction, kind, identity) + .await? + .map(outcome) + .ok_or(RequestOperationError::Binding) +} + +async fn read_by_dedup( + transaction: &mut ServiceSqliteTransaction<'_>, + kind: DedupKind, + identity: &[u8; 32], +) -> Result<Option<MycSignerRequestRecord>, RequestOperationError> { + let rows = sqlx::query(READ_BY_DEDUP_SQL) + .bind(kind.as_str()) + .bind(identity.as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| RequestOperationError::Storage)?; + if rows.len() > 1 { + return Err(RequestOperationError::Binding); + } + rows.first() + .map(|row| parse_record(row, kind, identity)) + .transpose() +} + +fn parse_record( + row: &sqlx::sqlite::SqliteRow, + selected_kind: DedupKind, + selected_identity: &[u8; 32], +) -> Result<MycSignerRequestRecord, RequestOperationError> { + let operation_id = MycSignerOperationId(exact_digest(row, "operation_id")?); + let correlation_id = MycSignerCorrelationId(exact_digest(row, "correlation_id")?); + let operation_nonce = exact_digest(row, "operation_nonce")?; + let request_identity = exact_digest(row, "request_identity_sha256")?; + let selected_request_digest = exact_digest(row, "selected_request_sha256")?; + let logical_request_digest = exact_digest(row, "logical_request_sha256")?; + let logical_operation_id = exact_digest(row, "logical_operation_id")?; + let request_digest = exact_digest(row, "request_sha256")?; + let client_public_key = MycNip46ClientPublicKey::new(bounded_text(row, "client_public_key")?) + .map_err(|_| RequestOperationError::Binding)?; + let request_id = MycNip46RequestId::new(bounded_text(row, "request_id")?) + .map_err(|_| RequestOperationError::Binding)?; + if (selected_kind == DedupKind::Request && selected_identity != &request_identity) + || selected_request_digest != request_digest + || logical_request_digest != request_digest + || logical_operation_id != *operation_id.as_bytes() + || derive_request_identity(&client_public_key, &request_id) != request_identity + || derive_operation_id(&request_identity, &MycSignerOperationNonce(operation_nonce)) + != *operation_id.as_bytes() + || derive_digest(CORRELATION_ID_DOMAIN, operation_id.as_bytes()) + != *correlation_id.as_bytes() + { + return Err(RequestOperationError::Binding); + } + let method = MycSignerRequestMethod::parse(bounded_text(row, "method")?) + .ok_or(RequestOperationError::Binding)?; + let received_at = bounded_time(row, "received_at_unix_ms")?; + let last_seen_at = bounded_time(row, "last_seen_at_unix_ms")?; + if last_seen_at < received_at { + return Err(RequestOperationError::Binding); + } + Ok(MycSignerRequestRecord { + operation_id, + correlation_id, + client_public_key, + request_id, + first_event_id: MycNip46EventId(exact_digest(row, "first_event_id")?), + method, + request_digest: MycSignerRequestDigest(request_digest), + received_at, + replay_count: bounded_count(row, "replay_count")?, + conflict_count: bounded_count(row, "conflict_count")?, + last_seen_at, + }) +} + +fn exact_request(existing: &MycSignerRequestRecord, request: &MycSignerRequest) -> bool { + existing.client_public_key == request.client_public_key + && existing.request_id == request.request_id + && existing.method == request.method + && existing.request_digest == request.request_digest +} + +fn exact_digest( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<[u8; 32], RequestOperationError> { + row.try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| RequestOperationError::Binding)? + .ok_or(RequestOperationError::Binding)? + .try_into() + .map_err(|_| RequestOperationError::Binding) +} + +fn bounded_text<'row>( + row: &'row sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<&'row str, RequestOperationError> { + row.try_get::<Option<&str>, _>(column) + .map_err(|_| RequestOperationError::Binding)? + .ok_or(RequestOperationError::Binding) +} + +fn bounded_time( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<MycRequestReceivedAtUnixMs, RequestOperationError> { + let value = row + .try_get::<i64, _>(column) + .map_err(|_| RequestOperationError::Binding)?; + let value = u64::try_from(value).map_err(|_| RequestOperationError::Binding)?; + MycRequestReceivedAtUnixMs::new(value).map_err(|_| RequestOperationError::Binding) +} + +fn bounded_count( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<u64, RequestOperationError> { + let value = row + .try_get::<i64, _>(column) + .map_err(|_| RequestOperationError::Binding)?; + u64::try_from(value).map_err(|_| RequestOperationError::Binding) +} + +fn derive_request_identity( + client_public_key: &MycNip46ClientPublicKey, + request_id: &MycNip46RequestId, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(REQUEST_IDENTITY_DOMAIN); + update_length_prefixed(&mut hasher, client_public_key.as_hex().as_bytes()); + update_length_prefixed(&mut hasher, request_id.as_str().as_bytes()); + hasher.finalize().into() +} + +fn derive_digest(domain: &[u8], value: &[u8; 32]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(value); + hasher.finalize().into() +} + +fn derive_operation_id(request_identity: &[u8; 32], nonce: &MycSignerOperationNonce) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(OPERATION_ID_DOMAIN); + hasher.update(request_identity); + hasher.update(nonce.0); + hasher.finalize().into() +} + +fn update_length_prefixed(hasher: &mut Sha256, value: &[u8]) { + hasher.update( + u64::try_from(value.len()) + .expect("bounded identity length fits u64") + .to_be_bytes(), + ); + hasher.update(value); +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<RequestOperationError>, +) -> MycStateRepositoryError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return MycStateRepositoryError::new(MycStateRepositoryErrorKind::CommitOutcomeUnknown); + } + let kind = match error.operation_error() { + Some(RequestOperationError::Binding) => MycStateRepositoryErrorKind::Binding, + Some(RequestOperationError::Storage) | None => MycStateRepositoryErrorKind::Transaction, + }; + MycStateRepositoryError::new(kind) +} diff --git a/tests/services_hardening_signer_request_state.rs b/tests/services_hardening_signer_request_state.rs @@ -0,0 +1,646 @@ +#![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_NIP46_CANONICAL_REQUEST_MAX_BYTES, MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES, + MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycNip46ClientPublicKey, MycNip46EventId, + MycNip46RequestId, MycRequestReceivedAtUnixMs, MycSignerOperationNonce, MycSignerRequest, + MycSignerRequestAdmission, MycSignerRequestDigest, MycSignerRequestErrorKind, + MycSignerRequestMethod, MycStateMetadata, 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 REQUEST_SOURCE: &str = include_str!("../src/state_request.rs"); +const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs"); +const CLIENT_PUBLIC_KEY: &str = "2222222222222222222222222222222222222222222222222222222222222222"; +const REQUEST_BYTES: &[u8] = b"{\"id\":\"request-01\",\"method\":\"ping\",\"params\":[]}"; + +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 request( + request_id: &str, + event_byte: u8, + method: MycSignerRequestMethod, + bytes: &[u8], + nonce_byte: u8, + received_at: u64, +) -> MycSignerRequest { + MycSignerRequest::new( + MycNip46ClientPublicKey::new(CLIENT_PUBLIC_KEY).expect("client identity"), + MycNip46RequestId::new(request_id).expect("request ID"), + MycNip46EventId::from_bytes([event_byte; 32]), + method, + MycSignerRequestDigest::for_canonical_request(bytes).expect("request digest"), + MycSignerOperationNonce::from_injected_entropy([nonce_byte; 32]), + MycRequestReceivedAtUnixMs::new(received_at).expect("received time"), + ) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[test] +fn request_inputs_ids_methods_and_diagnostics_are_closed_bounded_and_stable() { + let maximum_id = "a".repeat(MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES); + assert!(MycNip46RequestId::new(&maximum_id).is_ok()); + for invalid in [ + "", + " request", + "request ", + "request\n", + &"a".repeat(MYC_NIP46_REQUEST_ID_MAX_UTF8_BYTES + 1), + &"x".repeat(1024 * 1024), + ] { + assert_eq!( + MycNip46RequestId::new(invalid) + .expect_err("invalid request ID") + .kind(), + MycSignerRequestErrorKind::InvalidRequestId + ); + } + + assert!(MycNip46ClientPublicKey::new(CLIENT_PUBLIC_KEY).is_ok()); + for invalid in [ + "22", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + ] { + assert_eq!( + MycNip46ClientPublicKey::new(invalid) + .expect_err("invalid client identity") + .kind(), + MycSignerRequestErrorKind::InvalidClientIdentity + ); + } + + let maximum_request = vec![b'x'; MYC_NIP46_CANONICAL_REQUEST_MAX_BYTES]; + assert!(MycSignerRequestDigest::for_canonical_request(&maximum_request).is_ok()); + assert_eq!( + MycSignerRequestDigest::for_canonical_request(&[]) + .expect_err("empty request") + .kind(), + MycSignerRequestErrorKind::InvalidCanonicalRequest + ); + assert_eq!( + MycSignerRequestDigest::for_canonical_request(&vec![ + b'x'; + MYC_NIP46_CANONICAL_REQUEST_MAX_BYTES + + 1 + ]) + .expect_err("oversized request") + .kind(), + MycSignerRequestErrorKind::InvalidCanonicalRequest + ); + + assert!(MycRequestReceivedAtUnixMs::new(i64::MAX.unsigned_abs()).is_ok()); + for invalid in [0, i64::MAX.unsigned_abs() + 1] { + assert_eq!( + MycRequestReceivedAtUnixMs::new(invalid) + .expect_err("invalid time") + .kind(), + MycSignerRequestErrorKind::InvalidReceivedAt + ); + } + + let methods = [ + (MycSignerRequestMethod::Connect, "connect"), + (MycSignerRequestMethod::GetPublicKey, "get_public_key"), + ( + MycSignerRequestMethod::GetSessionCapability, + "get_session_capability", + ), + (MycSignerRequestMethod::SignEvent, "sign_event"), + (MycSignerRequestMethod::Nip04Encrypt, "nip04_encrypt"), + (MycSignerRequestMethod::Nip04Decrypt, "nip04_decrypt"), + (MycSignerRequestMethod::Nip44Encrypt, "nip44_encrypt"), + (MycSignerRequestMethod::Nip44Decrypt, "nip44_decrypt"), + (MycSignerRequestMethod::Ping, "ping"), + (MycSignerRequestMethod::SwitchRelays, "switch_relays"), + (MycSignerRequestMethod::Logout, "logout"), + ]; + assert_eq!( + methods.map(|(method, _)| method.as_str()), + methods.map(|(_, wire)| wire) + ); + + assert_eq!( + hex(MycSignerRequestDigest::for_canonical_request(REQUEST_BYTES) + .expect("digest") + .as_bytes()), + "378d4f7906aed41e5af96c7000dfc57d9c4c4c9ad3d94b84e985621c25e727f2" + ); + let nonce = MycSignerOperationNonce::from_injected_entropy([0x5a; 32]); + assert_eq!(format!("{nonce:?}"), "MycSignerOperationNonce([redacted])"); + + let error = MycNip46RequestId::new(" secret\n").expect_err("invalid input"); + assert!(Error::source(&error).is_none()); + let request = request( + "request-01", + 0x44, + MycSignerRequestMethod::Ping, + REQUEST_BYTES, + 1, + 1, + ); + let rendered = format!("{request:?} {error} {error:?}"); + for secret in [CLIENT_PUBLIC_KEY, "request-01", "secret", "378d4f79"] { + assert!(!rendered.contains(secret)); + } +} + +#[tokio::test] +async fn request_admission_is_atomic_idempotent_conflict_aware_and_restart_stable() { + 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 first = request( + "request-01", + 0x44, + MycSignerRequestMethod::Ping, + REQUEST_BYTES, + 100, + 100, + ); + let admitted = host + .repository() + .admit_signer_request(&first) + .await + .expect("first admission"); + assert!(matches!(admitted, MycSignerRequestAdmission::Admitted(_))); + assert_eq!(admitted.record().replay_count(), 0); + assert_eq!(admitted.record().conflict_count(), 0); + assert_eq!( + hex(admitted.record().operation_id().as_bytes()), + "c72e6b8e1824b94e8ce6284cff532895c3e8b80c7859e02decfc7fda88134444" + ); + assert_eq!( + hex(admitted.record().correlation_id().as_bytes()), + "87d551e4c196fded2fa4d9335655711f64da873c552e7338f8e99541eb871654" + ); + assert_ne!( + admitted.record().operation_id().as_bytes(), + admitted.record().correlation_id().as_bytes() + ); + let first_operation_id = admitted.record().operation_id(); + let first_correlation_id = admitted.record().correlation_id(); + + let same_event = request( + "request-01", + 0x44, + MycSignerRequestMethod::Ping, + REQUEST_BYTES, + 101, + 101, + ); + let replay = host + .repository() + .admit_signer_request(&same_event) + .await + .expect("same-event replay"); + assert!(matches!(replay, MycSignerRequestAdmission::ExactReplay(_))); + assert_eq!(replay.record().replay_count(), 1); + assert_eq!(replay.record().operation_id(), first_operation_id); + assert_eq!(replay.record().correlation_id(), first_correlation_id); + + let new_event = request( + "request-01", + 0x45, + MycSignerRequestMethod::Ping, + REQUEST_BYTES, + 102, + 102, + ); + let replay = host + .repository() + .admit_signer_request(&new_event) + .await + .expect("new-event replay"); + assert!(matches!(replay, MycSignerRequestAdmission::ExactReplay(_))); + assert_eq!(replay.record().replay_count(), 2); + + let changed_payload = request( + "request-01", + 0x46, + MycSignerRequestMethod::GetPublicKey, + b"{\"id\":\"request-01\",\"method\":\"get_public_key\",\"params\":[]}", + 103, + 103, + ); + let conflict = host + .repository() + .admit_signer_request(&changed_payload) + .await + .expect("request conflict"); + assert!(matches!( + conflict, + MycSignerRequestAdmission::ConflictingReuse(_) + )); + assert_eq!(conflict.record().method(), MycSignerRequestMethod::Ping); + assert_eq!(conflict.record().conflict_count(), 1); + + let reused_event = request( + "request-02", + 0x44, + MycSignerRequestMethod::Ping, + b"{\"id\":\"request-02\",\"method\":\"ping\",\"params\":[]}", + 104, + 104, + ); + let conflict = host + .repository() + .admit_signer_request(&reused_event) + .await + .expect("event conflict"); + assert!(matches!( + conflict, + MycSignerRequestAdmission::ConflictingReuse(_) + )); + assert_eq!(conflict.record().operation_id(), first_operation_id); + let rendered = format!("{admitted:?} {:?}", admitted.record()); + for secret in [CLIENT_PUBLIC_KEY, "request-01", "c72e6b8e"] { + assert!(!rendered.contains(secret)); + } + host.close().await.expect("close after first lifecycle"); + + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("reopened writable host"); + let after_reopen = request( + "request-01", + 0x47, + MycSignerRequestMethod::Ping, + REQUEST_BYTES, + 105, + 105, + ); + let replay = host + .repository() + .admit_signer_request(&after_reopen) + .await + .expect("replay after reopen"); + assert!(matches!(replay, MycSignerRequestAdmission::ExactReplay(_))); + assert_eq!(replay.record().replay_count(), 3); + assert_eq!(replay.record().conflict_count(), 1); + host.close().await.expect("final 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("test inspection connection"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nip46_requests") + .fetch_one(&mut connection) + .await + .expect("request count"), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nip46_request_dedup") + .fetch_one(&mut connection) + .await + .expect("dedup count"), + 4 + ); + let event_conflicts = sqlx::query_scalar::<_, i64>( + "SELECT conflict_count FROM nip46_request_dedup \ + WHERE dedup_kind = 'event' AND identity_sha256 = ?", + ) + .bind([0x44_u8; 32].as_slice()) + .fetch_one(&mut connection) + .await + .expect("event conflict count"); + assert_eq!(event_conflicts, 1); + assert!( + sqlx::query("UPDATE nip46_requests SET received_at_unix_ms = 999") + .execute(&mut connection) + .await + .is_err() + ); + connection.close().await.expect("inspection close"); +} + +#[tokio::test] +async fn concurrent_identical_admission_creates_one_request_and_bounded_replay_evidence() { + 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 request = request( + "request-concurrent", + 0x55, + MycSignerRequestMethod::Ping, + b"concurrent", + 200, + 200, + ); + let repository = host.repository(); + + let (a, b, c, d, e, f, g, h) = tokio::join!( + repository.admit_signer_request(&request), + repository.admit_signer_request(&request), + repository.admit_signer_request(&request), + repository.admit_signer_request(&request), + repository.admit_signer_request(&request), + repository.admit_signer_request(&request), + repository.admit_signer_request(&request), + repository.admit_signer_request(&request), + ); + let outcomes = [a, b, c, d, e, f, g, h] + .into_iter() + .collect::<Result<Vec<_>, _>>() + .expect("concurrent admissions"); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, MycSignerRequestAdmission::Admitted(_))) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, MycSignerRequestAdmission::ExactReplay(_))) + .count(), + 7 + ); + let final_record = outcomes + .iter() + .max_by_key(|outcome| outcome.record().replay_count()) + .expect("final replay record") + .record(); + assert_eq!(final_record.replay_count(), 7); + assert_eq!(final_record.conflict_count(), 0); + host.close().await.expect("host close"); +} + +#[tokio::test] +async fn exact_schema_v2_state_advances_to_v3_before_request_admission() { + 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 options = SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = sqlx::SqliteConnection::connect_with(&options) + .await + .expect("downgrade fixture connection"); + let shared_update = sqlx::query_scalar::<_, String>( + "SELECT sql FROM sqlite_schema WHERE type = 'trigger' \ + AND name = 'radroots_service_metadata_guard_update'", + ) + .fetch_one(&mut connection) + .await + .expect("shared update trigger"); + let myc_update = sqlx::query_scalar::<_, String>( + "SELECT sql FROM sqlite_schema WHERE type = 'trigger' \ + AND name = 'myc_state_metadata_no_update'", + ) + .fetch_one(&mut connection) + .await + .expect("Myc update trigger"); + let migration_delete = sqlx::query_scalar::<_, String>( + "SELECT sql FROM sqlite_schema WHERE type = 'trigger' \ + AND name = 'schema_migrations_no_delete'", + ) + .fetch_one(&mut connection) + .await + .expect("migration delete trigger"); + for sql in [ + "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", + ] { + sqlx::query(sql) + .execute(&mut connection) + .await + .expect("construct exact schema-v2 fixture"); + } + for sql in [&shared_update, &myc_update, &migration_delete] { + sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str())) + .execute(&mut connection) + .await + .expect("restore exact governed trigger"); + } + connection.close().await.expect("fixture connection close"); + + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("schema-v2 upgrade"); + let admitted = host + .repository() + .admit_signer_request(&request( + "request-after-v2", + 0x66, + MycSignerRequestMethod::Ping, + b"after-v2", + 0x66, + 300, + )) + .await + .expect("request admission after migration"); + assert!(matches!(admitted, MycSignerRequestAdmission::Admitted(_))); + host.close().await.expect("upgraded host close"); +} + +#[tokio::test] +async fn persisted_operation_entropy_tampering_fails_closed_as_a_binding_error() { + 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 request = request( + "request-tamper", + 0x77, + MycSignerRequestMethod::Ping, + b"tamper-check", + 0x77, + 400, + ); + host.repository() + .admit_signer_request(&request) + .await + .expect("initial request admission"); + host.close().await.expect("host close before tamper"); + + let options = SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = sqlx::SqliteConnection::connect_with(&options) + .await + .expect("tamper fixture connection"); + let request_guard = sqlx::query_scalar::<_, String>( + "SELECT sql FROM sqlite_schema WHERE type = 'trigger' \ + AND name = 'nip46_requests_no_update'", + ) + .fetch_one(&mut connection) + .await + .expect("request guard"); + sqlx::query("DROP TRIGGER nip46_requests_no_update") + .execute(&mut connection) + .await + .expect("drop request guard for corruption fixture"); + sqlx::query("UPDATE nip46_requests SET operation_nonce = zeroblob(32)") + .execute(&mut connection) + .await + .expect("corrupt persisted operation nonce"); + sqlx::raw_sql(sqlx::AssertSqlSafe(request_guard.as_str())) + .execute(&mut connection) + .await + .expect("restore request guard"); + connection.close().await.expect("fixture connection close"); + + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("reopen structurally valid state"); + let error = host + .repository() + .admit_signer_request(&request) + .await + .expect_err("tampered operation binding"); + assert_eq!(error.kind(), myc::MycStateRepositoryErrorKind::Binding); + host.close().await.expect("host close after rejection"); +} + +#[test] +fn request_store_is_sealed_transactional_bounded_and_independent_of_external_effects() { + assert!(REQUEST_SOURCE.contains("ServiceSqliteTransaction<'_>")); + assert!(REQUEST_SOURCE.contains("require_expected_metadata(transaction, &expected)")); + assert!(REQUEST_SOURCE.contains("CASE WHEN typeof(r.operation_id) = 'blob'")); + assert!(REQUEST_SOURCE.contains("END AS logical_operation_id")); + assert!(REQUEST_SOURCE.contains("LIMIT 2")); + assert!(REQUEST_SOURCE.contains("replay_count < 9223372036854775807")); + assert!(REQUEST_SOURCE.contains("conflict_count < 9223372036854775807")); + assert!(CATALOG_SOURCE.contains("CREATE TABLE nip46_requests")); + assert!(CATALOG_SOURCE.contains("CREATE TABLE nip46_request_dedup")); + assert!(CATALOG_SOURCE.contains("nip46_request_dedup_guard_update")); + for forbidden in [ + "SqlitePool", + "SqliteConnection", + "BEGIN ", + "COMMIT", + "ROLLBACK", + "std::fs", + "std::env", + "SystemTime", + "Instant::now", + "tokio::spawn", + "spawn_blocking", + "rand::", + "getrandom", + "SystemEntropy", + "reqwest", + "RelayPool", + "provider.await", + ] { + assert!( + !REQUEST_SOURCE.contains(forbidden), + "found forbidden request-store authority `{forbidden}`" + ); + } +} diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -7,8 +7,9 @@ use myc::{ MYC_STATE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, 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, - MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, - validate_myc_state_catalogs, + 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, }; use radroots_service_sqlite::{ MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest, @@ -20,27 +21,34 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_v2_and_single_migration_have_exact_literal_identities() { +fn schema_v1_through_v3_and_both_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, 2); - assert_eq!(migrations.descriptors().len(), 1); - let migration = &migrations.descriptors()[0]; - assert_eq!(migration.target_version(), 2); - assert_eq!(migration.name().as_str(), "create_myc_state_metadata"); + assert_eq!(MYC_STATE_SCHEMA_VERSION, 3); + assert_eq!(migrations.descriptors().len(), 2); + let metadata = &migrations.descriptors()[0]; + assert_eq!(metadata.target_version(), 2); + assert_eq!(metadata.name().as_str(), "create_myc_state_metadata"); assert_eq!( - migration.checksum().as_bytes(), + metadata.checksum().as_bytes(), &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256 ); - assert_eq!(migrations.current_version(), 2); + let request = &migrations.descriptors()[1]; + assert_eq!(request.target_version(), 3); + assert_eq!(request.name().as_str(), "create_nip46_request_admission"); + assert_eq!( + request.checksum().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256 + ); + assert_eq!(migrations.current_version(), 3); assert_eq!( migrations.digest().as_bytes(), &MYC_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 2); + assert_eq!(schema.versions().len(), 3); assert_eq!(schema.versions()[0].version(), 1); assert_eq!( schema.versions()[0].object_count(), @@ -60,6 +68,16 @@ fn schema_v1_v2_and_single_migration_have_exact_literal_identities() { schema.versions()[1].digest().as_bytes(), &MYC_STATE_SCHEMA_VERSION_2_SHA256 ); + assert_eq!(schema.versions()[2].version(), 3); + assert_eq!( + schema.versions()[2].object_count(), + MYC_STATE_SCHEMA_VERSION_3_OBJECT_COUNT + ); + assert_eq!(schema.versions()[2].object_count(), 13); + assert_eq!( + schema.versions()[2].digest().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_3_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"); @@ -70,7 +88,7 @@ fn schema_v1_v2_and_single_migration_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_MIGRATION_CATALOG_SHA256), - "be15584e4e6fe1f5b80209e8f6125ecc9281fc22e9769a79f210c30c431ff462" + "3d79b719ea3fe463e266f5ed0d1f091e3c33177c17820d21bd8596dfbd31aa9e" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256), @@ -82,7 +100,15 @@ fn schema_v1_v2_and_single_migration_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256), - "673f8ba2095ee02e8048af850d294436cb7b8150e91693b7727fae05812e831c" + "265564d09567724fac621d1e00c37dbccbe3cc24a9fab00e59ae70c6fe89872b" + ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256), + "753165136b3dace0091d782f33f6b10ca1a2823314158d80a4775e0af628edf9" + ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_3_SHA256), + "572fe6a4d36c0476ec40536f48028e1558488fb8abeba0a34ba69b4b7080ba08" ); } @@ -121,8 +147,12 @@ fn independent_validator_rejects_migration_or_schema_drift() { .expect("schema object"); let snapshot_digest = SchemaVersionCatalog::computed_digest(2, [object.clone()]).expect("snapshot digest"); - let v2 = SchemaVersionCatalog::new(2, [object], snapshot_digest).expect("schema v2"); - let schema = SchemaCatalog::new(&expected_migrations, [v1, v2]).expect("drift schema catalog"); + 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 schema = + SchemaCatalog::new(&expected_migrations, [v1, v2, v3]).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 @@ -115,12 +115,21 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { .await .expect("shared metadata row"); assert_eq!(row.get::<i64, _>(0), i64::from(MYC_STATE_SCHEMA_VERSION)); - let migration = sqlx::query("SELECT version, name FROM schema_migrations LIMIT 2") - .fetch_one(&mut connection) + let migrations = sqlx::query("SELECT version, name FROM schema_migrations ORDER BY version") + .fetch_all(&mut connection) .await - .expect("migration row"); - assert_eq!(migration.get::<i64, _>(0), 2); - assert_eq!(migration.get::<String, _>(1), "create_myc_state_metadata"); + .expect("migration rows"); + assert_eq!(migrations.len(), 2); + assert_eq!(migrations[0].get::<i64, _>(0), 2); + assert_eq!( + migrations[0].get::<String, _>(1), + "create_myc_state_metadata" + ); + assert_eq!(migrations[1].get::<i64, _>(0), 3); + assert_eq!( + migrations[1].get::<String, _>(1), + "create_nip46_request_admission" + ); let binding = sqlx::query( "SELECT normalized_config_sha256, transport_public_key, user_public_key, \ discovery_public_key, config_contract_version, state_contract_version, \ @@ -151,7 +160,7 @@ 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), 2); + assert_eq!(binding.get::<i64, _>(5), 3); 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 @@ -175,7 +175,7 @@ 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(), 2); + assert_eq!(manifest.state_schema_version().get(), 3); assert!(!manifest.protected_material_included()); let members = fs::read_dir(&bundle) .expect("backup directory") @@ -281,7 +281,7 @@ 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(), 2); + assert_eq!(verified.database_metadata().state_schema_version().get(), 3); let staged = stage_myc_state_restore(&runtime, &metadata, verified) .await .expect("offline staging");