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:
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");