myc

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

commit c45fd07cb6e920c71dabd2d90a85f490d30e7647
parent 1be8aa782864713f5011a2f0298e24262551c634
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 14:33:40 +0000

state: seal Myc metadata repository

- add the governed schema-v2 migration and immutable metadata binding\n- route repository mutation through ServiceSqliteTransaction\n- verify exact binding before writable or inspection host exposure

Diffstat:
MREADME | 17+++++++++++++++++
Msrc/lib.rs | 10++++++++--
Msrc/state_catalog.rs | 185++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/state_host.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/state_metadata.rs | 27++++++++++++++++++++-------
Asrc/state_repository.rs | 336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_state_catalog.rs | 111+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mtests/services_hardening_state_host.rs | 38+++++++++++++++++++++++++++++++++-----
Mtests/services_hardening_state_metadata.rs | 18+++++++++++++-----
Atests/services_hardening_state_repository.rs | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1037 insertions(+), 106 deletions(-)

diff --git a/README b/README @@ -43,6 +43,23 @@ host-environment resolver, worker namespace, ambient selector, or implicit path default. An explicit absolute `--config` may select the document to read without changing the canonical common artifact inventory. +## Governed state boundary + +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. + +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. + ## NIP-46 runtime contract Myc listens for encrypted kind-24133 requests on the exact configured relay diff --git a/src/lib.rs b/src/lib.rs @@ -28,6 +28,7 @@ pub mod sql; mod state_catalog; mod state_host; mod state_metadata; +mod state_repository; pub mod transport; pub use app::{ @@ -108,8 +109,10 @@ pub use runtime_context::{ resolve_myc_runtime_context, }; pub use state_catalog::{ - MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_SCHEMA_CATALOG_SHA256, MYC_STATE_SCHEMA_VERSION, - MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_1_SHA256, + MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_CATALOG_SHA256, + 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, }; @@ -122,4 +125,7 @@ pub use state_metadata::{ MycExpectedPublicIdentity, MycNormalizedConfigDigest, MycStateMetadata, MycStateMetadataError, MycStateMetadataErrorKind, MycStatePolicyVersions, }; +pub use state_repository::{ + MycStateRepository, MycStateRepositoryError, MycStateRepositoryErrorKind, +}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/src/state_catalog.rs b/src/state_catalog.rs @@ -4,20 +4,21 @@ use core::fmt; use std::error::Error; use radroots_service_sqlite::{ - MigrationCatalog, SchemaCatalog, SchemaDigest, SchemaVersionCatalog, + MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest, + SchemaObject, SchemaObjectKind, SchemaVersionCatalog, }; -/// The clean-slate Myc baseline schema version. -pub const MYC_STATE_SCHEMA_VERSION: u32 = 1; +/// The shared create-new baseline written before service migrations run. +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; /// The shared metadata and migration-ledger objects present at schema v1. pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; -/// SHA-256 identity of the empty schema-v1 migration catalog. -pub const MYC_MIGRATION_CATALOG_SHA256: [u8; 32] = [ - 0xec, 0x89, 0xdc, 0x8f, 0x7b, 0x6c, 0x2a, 0x11, 0xb9, 0x67, 0xe3, 0x38, 0x08, 0xe4, 0x03, 0x1e, - 0x29, 0xb3, 0x97, 0x0f, 0xfe, 0xe4, 0x95, 0x9b, 0xff, 0x9b, 0xad, 0x35, 0x28, 0x77, 0xee, 0x9b, -]; +/// The shared objects plus the three immutable Myc metadata objects at schema v2. +pub const MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT: u32 = 9; /// SHA-256 identity of the exact schema-v1 object snapshot. pub const MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [ @@ -25,12 +26,108 @@ pub const MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [ 0x11, 0x9f, 0x5b, 0xd9, 0x2b, 0x04, 0x39, 0x0c, 0x67, 0xf6, 0x98, 0xa0, 0x36, 0xfa, 0x78, 0xae, ]; +/// SHA-256 identity of the schema-v2 object snapshot. +pub const MYC_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32] = [ + 0x94, 0x73, 0x9b, 0x5a, 0x34, 0xca, 0x8e, 0xd1, 0x30, 0xb9, 0x46, 0xd0, 0x92, 0x73, 0x1b, 0x59, + 0xbf, 0x15, 0x48, 0xfe, 0x54, 0x1d, 0x9a, 0x92, 0x69, 0xa3, 0x3d, 0x0a, 0xed, 0x1e, 0xa0, 0x9e, +]; + +/// 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, +]; + /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x23, 0x09, 0x15, 0x3f, 0x3b, 0x49, 0x75, 0x48, 0x87, 0xc5, 0x48, 0xa7, 0x45, 0x9b, 0x3e, 0x09, - 0x09, 0x9c, 0x60, 0xf7, 0x14, 0x6b, 0x37, 0x3c, 0x8f, 0x96, 0x70, 0x6c, 0x67, 0x68, 0xd7, 0x91, + 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, +]; + +/// SHA-256 identity of the schema-v2 migration content. +pub const MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32] = [ + 0xc5, 0xeb, 0x97, 0x8b, 0xda, 0x1b, 0xc7, 0x0c, 0x70, 0xbd, 0x9b, 0xd4, 0x4d, 0x8c, 0xba, 0x70, + 0xcb, 0x28, 0x57, 0xd2, 0x6a, 0x9d, 0xce, 0x74, 0x96, 0x1f, 0x4b, 0x78, 0x1e, 0x71, 0x00, 0x37, ]; +/// 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, + 0xb2, 0x83, 0xee, 0xbe, 0x8b, 0xcc, 0x95, 0x72, 0x38, 0xad, 0xaa, 0x30, 0x78, 0xc0, 0x29, 0x1a, +]; + +/// SHA-256 identity of the Myc metadata update guard. +const MYC_STATE_METADATA_NO_UPDATE_SHA256: [u8; 32] = [ + 0xf0, 0xe3, 0x30, 0xf2, 0x19, 0x63, 0xd4, 0x94, 0xf8, 0x02, 0xf3, 0x55, 0x78, 0x4b, 0x45, 0x1c, + 0xde, 0x2b, 0xd2, 0xc8, 0x0d, 0x90, 0x22, 0x33, 0x0e, 0x61, 0x03, 0x97, 0xd4, 0x3c, 0xbe, 0x08, +]; + +/// SHA-256 identity of the Myc metadata delete guard. +const MYC_STATE_METADATA_NO_DELETE_SHA256: [u8; 32] = [ + 0x05, 0x32, 0x87, 0x93, 0x6d, 0xbb, 0xae, 0x52, 0x0b, 0xff, 0x25, 0xfe, 0x87, 0xd5, 0xd2, 0xd1, + 0xa3, 0xcc, 0xf2, 0x81, 0xc3, 0x5b, 0x14, 0xa0, 0x24, 0xa7, 0x74, 0xa5, 0x67, 0x50, 0x3d, 0x4c, +]; + +macro_rules! myc_state_metadata_table_sql { + () => { + r#"CREATE TABLE myc_state_metadata ( + singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1), + normalized_config_sha256 BLOB NOT NULL CHECK (length(normalized_config_sha256) = 32), + transport_public_key TEXT NOT NULL + CHECK (length(CAST(transport_public_key AS BLOB)) = 64) + CHECK (transport_public_key NOT GLOB '*[^0-9a-f]*'), + user_public_key TEXT NOT NULL + CHECK (length(CAST(user_public_key AS BLOB)) = 64) + CHECK (user_public_key NOT GLOB '*[^0-9a-f]*'), + discovery_public_key TEXT + CHECK (discovery_public_key IS NULL OR ( + length(CAST(discovery_public_key AS BLOB)) = 64 + AND discovery_public_key NOT GLOB '*[^0-9a-f]*' + )), + config_contract_version INTEGER NOT NULL + CHECK (config_contract_version BETWEEN 1 AND 4294967295), + state_contract_version INTEGER NOT NULL + CHECK (state_contract_version BETWEEN 1 AND 4294967295), + operator_contract_version INTEGER NOT NULL + CHECK (operator_contract_version BETWEEN 1 AND 4294967295), + status_contract_version INTEGER NOT NULL + CHECK (status_contract_version BETWEEN 1 AND 4294967295) +) STRICT"# + }; +} + +macro_rules! myc_state_metadata_no_update_sql { + () => { + r#"CREATE TRIGGER myc_state_metadata_no_update +BEFORE UPDATE ON myc_state_metadata +BEGIN + SELECT RAISE(ABORT, 'Myc state metadata is immutable'); +END"# + }; +} + +macro_rules! myc_state_metadata_no_delete_sql { + () => { + r#"CREATE TRIGGER myc_state_metadata_no_delete +BEFORE DELETE ON myc_state_metadata +BEGIN + SELECT RAISE(ABORT, 'Myc state metadata is immutable'); +END"# + }; +} + +const CREATE_MYC_STATE_METADATA_TABLE_SQL: &str = myc_state_metadata_table_sql!(); +const CREATE_MYC_STATE_METADATA_NO_UPDATE_SQL: &str = myc_state_metadata_no_update_sql!(); +const CREATE_MYC_STATE_METADATA_NO_DELETE_SQL: &str = myc_state_metadata_no_delete_sql!(); + +const CREATE_MYC_STATE_METADATA_MIGRATION_SQL: &str = concat!( + myc_state_metadata_table_sql!(), + ";\n", + myc_state_metadata_no_update_sql!(), + ";\n", + myc_state_metadata_no_delete_sql!(), +); + /// Stable classes for invalid embedded Myc catalog definitions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MycStateCatalogErrorKind { @@ -68,7 +165,7 @@ impl MycStateCatalogError { self.kind } - /// Returns the stable machine-readable code. + /// Returns the stable machine-readable failure code. #[must_use] pub const fn code(self) -> &'static str { self.kind.code() @@ -100,12 +197,19 @@ impl fmt::Debug for MycStateCatalogError { impl Error for MycStateCatalogError {} -/// Constructs the exact schema-v1 migration catalog. +/// Constructs the exact ordered Myc migration catalog. pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> { - let catalog = MigrationCatalog::new([]) + let migration = MigrationDescriptor::sql( + MYC_STATE_SCHEMA_VERSION, + "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]) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; if catalog.current_version() != MYC_STATE_SCHEMA_VERSION - || !catalog.descriptors().is_empty() + || catalog.descriptors().len() != 1 || catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256 { return Err(MycStateCatalogError::new( @@ -118,32 +222,73 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> /// Constructs the exact Myc schema catalog bound to the migration catalog. pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { let migrations = myc_migration_catalog()?; - let version = SchemaVersionCatalog::new( - MYC_STATE_SCHEMA_VERSION, + let version_one = SchemaVersionCatalog::new( + MYC_STATE_BASE_SCHEMA_VERSION, [], SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_1_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; - let catalog = SchemaCatalog::new(&migrations, [version]) + let version_two = SchemaVersionCatalog::new( + MYC_STATE_SCHEMA_VERSION, + 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]) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; validate_myc_state_catalogs(&migrations, &catalog)?; Ok(catalog) } +fn myc_state_metadata_objects() -> Result<[SchemaObject; 3], MycStateCatalogError> { + let table = SchemaObject::new( + SchemaObjectKind::Table, + "myc_state_metadata", + "myc_state_metadata", + CREATE_MYC_STATE_METADATA_TABLE_SQL, + SchemaDigest::from_bytes(MYC_STATE_METADATA_TABLE_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + let update = SchemaObject::new( + SchemaObjectKind::Trigger, + "myc_state_metadata_no_update", + "myc_state_metadata", + CREATE_MYC_STATE_METADATA_NO_UPDATE_SQL, + SchemaDigest::from_bytes(MYC_STATE_METADATA_NO_UPDATE_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + let delete = SchemaObject::new( + SchemaObjectKind::Trigger, + "myc_state_metadata_no_delete", + "myc_state_metadata", + CREATE_MYC_STATE_METADATA_NO_DELETE_SQL, + SchemaDigest::from_bytes(MYC_STATE_METADATA_NO_DELETE_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + Ok([table, update, delete]) +} + /// Independently validates exact catalog versions, counts, and digests. pub fn validate_myc_state_catalogs( migrations: &MigrationCatalog, schema: &SchemaCatalog, ) -> Result<(), MycStateCatalogError> { let versions = schema.versions(); + let descriptors = migrations.descriptors(); let valid = migrations.current_version() == MYC_STATE_SCHEMA_VERSION - && migrations.descriptors().is_empty() + && descriptors.len() == 1 + && descriptors[0].target_version() == MYC_STATE_SCHEMA_VERSION + && descriptors[0].name().as_str() == "create_myc_state_metadata" + && descriptors[0].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256 && migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 1 - && versions[0].version() == MYC_STATE_SCHEMA_VERSION + && versions.len() == 2 + && 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].object_count() == MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT + && versions[1].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_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 @@ -4,14 +4,14 @@ use core::fmt; use std::{error::Error, path::PathBuf}; use radroots_service_sqlite::{ - MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, + MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, initialize_database, }; use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions}; use crate::{ - MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, MycStateMetadata, myc_migration_catalog, - myc_schema_catalog, validate_myc_state_catalogs, + MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, MycStateMetadata, + MycStateRepository, myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs, }; /// Stable lifecycle mode of one opened Myc state host. @@ -30,6 +30,7 @@ pub enum MycStateHostErrorKind { Initialize, ReadWriteOpen, InspectionOpen, + Repository, Close, } @@ -44,6 +45,7 @@ impl MycStateHostErrorKind { Self::Initialize => "state_initialize_failed", Self::ReadWriteOpen => "state_read_write_open_failed", Self::InspectionOpen => "state_inspection_open_failed", + Self::Repository => "state_repository_failed", Self::Close => "state_close_failed", } } @@ -82,6 +84,7 @@ impl fmt::Display for MycStateHostError { MycStateHostErrorKind::Initialize => "Myc state initialization failed", MycStateHostErrorKind::ReadWriteOpen => "Myc writable state could not be opened", MycStateHostErrorKind::InspectionOpen => "Myc inspection state could not be opened", + MycStateHostErrorKind::Repository => "Myc state repository binding failed", MycStateHostErrorKind::Close => "Myc state host could not be closed", }) } @@ -139,6 +142,12 @@ impl MycStateHost { &self.metadata } + /// Returns sealed typed repository access bound to this host and metadata. + #[must_use] + pub const fn repository(&self) -> MycStateRepository<'_> { + MycStateRepository::new(&self.host, &self.metadata) + } + /// Drains the shared host and explicitly releases retained authority. pub async fn close(&self) -> Result<(), MycStateHostError> { self.host @@ -166,24 +175,53 @@ impl fmt::Debug for MycStateHost { pub async fn initialize_myc_state( runtime: &MycRuntimeContext, metadata: &MycStateMetadata, + applied_at: MigrationAppliedAtUnixSeconds, + build: &MigrationBuildIdentity, ) -> Result<(), MycStateHostError> { let paths = state_paths(runtime)?; require_metadata(runtime, metadata)?; + require_migration_build(metadata, build)?; let (migrations, schema) = catalogs()?; - let mut authority = initialize_database( + let authority = initialize_database( &paths, OpenMode::Initialize, - metadata.database(), + metadata.initial_database_metadata(), &schema, initialize_empty_catalog, ) .await .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?; - authority - .release() - .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?; - drop(migrations); - Ok(()) + let identity = metadata.database_identity(); + let (host, outcome) = ServiceSqliteHost::open_initialized( + &paths, + &identity, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + authority, + applied_at, + build, + &[], + ) + .await + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?; + let state = MycStateHost { + host, + mode: MycStateHostMode::ReadWriteExisting, + metadata: metadata.clone(), + }; + if !exact_initialization_outcome(outcome) { + let _ = state.close().await; + return Err(MycStateHostError::new(MycStateHostErrorKind::Catalog)); + } + if state.repository().bind_or_verify().await.is_err() { + let _ = state.close().await; + return Err(MycStateHostError::new(MycStateHostErrorKind::Repository)); + } + state + .close() + .await + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize)) } /// Opens an already initialized Myc catalog with exclusive writer authority. @@ -199,6 +237,7 @@ pub async fn open_myc_state_read_write( ) -> Result<MycStateHost, MycStateHostError> { let paths = state_paths(runtime)?; require_metadata(runtime, metadata)?; + require_migration_build(metadata, build)?; let identity = metadata.database_identity(); let (migrations, schema) = catalogs()?; let (host, outcome) = ServiceSqliteHost::open_read_write_existing( @@ -213,18 +252,20 @@ pub async fn open_myc_state_read_write( ) .await .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::ReadWriteOpen))?; - if outcome.initial_version() != MYC_STATE_SCHEMA_VERSION - || outcome.final_version() != MYC_STATE_SCHEMA_VERSION - || outcome.applied_count() != 0 - { + if !exact_existing_outcome(outcome) { let _ = host.close().await; return Err(MycStateHostError::new(MycStateHostErrorKind::Catalog)); } - Ok(MycStateHost { + let state = MycStateHost { host, mode: MycStateHostMode::ReadWriteExisting, metadata: metadata.clone(), - }) + }; + if state.repository().bind_or_verify().await.is_err() { + let _ = state.close().await; + return Err(MycStateHostError::new(MycStateHostErrorKind::Repository)); + } + Ok(state) } /// Opens an already initialized Myc catalog for immutable inspection. @@ -245,11 +286,16 @@ pub async fn open_myc_state_inspection( ) .await .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?; - Ok(MycStateHost { + let state = MycStateHost { host, mode: MycStateHostMode::ReadOnlyInspection, metadata: metadata.clone(), - }) + }; + if state.repository().verify_binding().await.is_err() { + let _ = state.close().await; + return Err(MycStateHostError::new(MycStateHostErrorKind::Repository)); + } + Ok(state) } fn state_paths(runtime: &MycRuntimeContext) -> Result<ServiceSqlitePaths, MycStateHostError> { @@ -261,16 +307,48 @@ fn require_metadata( runtime: &MycRuntimeContext, metadata: &MycStateMetadata, ) -> Result<(), MycStateHostError> { - let database = metadata.database(); + let database = metadata.initial_database_metadata(); + let identity = metadata.database_identity(); let matches = metadata.matches_runtime(runtime) && database.service() == runtime.context().service() && database.instance() == runtime.context().instance() - && database.state_schema_version().get() == MYC_STATE_SCHEMA_VERSION; + && database.state_schema_version().get() == MYC_STATE_BASE_SCHEMA_VERSION + && identity.service() == runtime.context().service() + && identity.instance() == runtime.context().instance() + && identity.supported_state_schema_version().get() == MYC_STATE_SCHEMA_VERSION; + matches + .then_some(()) + .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence)) +} + +fn require_migration_build( + metadata: &MycStateMetadata, + build: &MigrationBuildIdentity, +) -> Result<(), MycStateHostError> { + let versions = metadata.policy_versions(); + let matches = build.config_contract_version() == versions.configuration() + && build.state_contract_version() == versions.state() + && build.admin_contract_version() == versions.operator() + && build.status_contract_version() == versions.status(); matches .then_some(()) .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence)) } +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 +} + +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) + ) +} + fn catalogs() -> Result< ( radroots_service_sqlite::MigrationCatalog, diff --git a/src/state_metadata.rs b/src/state_metadata.rs @@ -12,8 +12,9 @@ use radroots_storage::event::SourceGeneration; use sha2::{Digest, Sha256}; use crate::{ - MYC_CONFIG_SCHEMA_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, MYC_STATE_SCHEMA_VERSION, - MycBootstrapProfileV1, MycConfigDocumentV1, MycConfigProfile, MycRuntimeContext, + MYC_CONFIG_SCHEMA_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, MYC_STATE_BASE_SCHEMA_VERSION, + MYC_STATE_SCHEMA_VERSION, MycBootstrapProfileV1, MycConfigDocumentV1, MycConfigProfile, + MycRuntimeContext, }; const NORMALIZED_CONFIG_DIGEST_DOMAIN: &[u8] = b"radroots.myc.normalized_config.v1\0"; @@ -163,6 +164,7 @@ impl MycStatePolicyVersions { pub struct MycStateMetadata { paths: ServiceSqlitePaths, database: ServiceDatabaseMetadata, + database_identity: ServiceDatabaseIdentity, configuration: MycNormalizedConfigDigest, identities: MycExpectedIdentities, policy_versions: MycStatePolicyVersions, @@ -182,7 +184,7 @@ impl MycStateMetadata { .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Paths))?; let application_id = ServiceSqliteApplicationId::new(MYC_STATE_APPLICATION_ID) .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?; - let state_schema_version = core::num::NonZeroU32::new(MYC_STATE_SCHEMA_VERSION) + let state_schema_version = core::num::NonZeroU32::new(MYC_STATE_BASE_SCHEMA_VERSION) .ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?; let database = ServiceDatabaseMetadata::new( &paths, @@ -192,6 +194,15 @@ impl MycStateMetadata { application_id, ) .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Database))?; + let supported_state_schema_version = + core::num::NonZeroU32::new(MYC_STATE_SCHEMA_VERSION) + .ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?; + let database_identity = ServiceDatabaseIdentity::new( + &paths, + source_generation, + supported_state_schema_version, + application_id, + ); let normalized = configuration.normalized(); let configuration = normalized_config_digest(configuration.profile(), normalized)?; let identities = expected_identities(normalized)?; @@ -211,22 +222,23 @@ impl MycStateMetadata { Ok(Self { paths, database, + database_identity, configuration, identities, policy_versions, }) } - /// Returns the shared immutable database metadata. + /// Returns the immutable shared schema-v1 initialization metadata. #[must_use] - pub const fn database(&self) -> &ServiceDatabaseMetadata { + pub const fn initial_database_metadata(&self) -> &ServiceDatabaseMetadata { &self.database } - /// Returns the reopen identity derived from the immutable database metadata. + /// Returns the reopen identity with this binary's exact schema ceiling. #[must_use] pub fn database_identity(&self) -> ServiceDatabaseIdentity { - self.database.identity() + self.database_identity.clone() } /// Returns the normalized configuration digest. @@ -258,6 +270,7 @@ impl fmt::Debug for MycStateMetadata { formatter .debug_struct("MycStateMetadata") .field("database", &self.database) + .field("database_identity", &self.database_identity) .field("configuration", &self.configuration) .field("identities", &self.identities) .field("policy_versions", &self.policy_versions) diff --git a/src/state_repository.rs b/src/state_repository.rs @@ -0,0 +1,336 @@ +//! Sealed typed access to Myc-owned SQLite state. + +use core::fmt; +use std::error::Error; + +use radroots_service_sqlite::{ + ServiceSqliteHost, ServiceSqliteTransaction, ServiceSqliteTransactionError, + ServiceSqliteTransactionErrorKind, +}; +use sqlx::Row; + +use crate::MycStateMetadata; + +const READ_METADATA_SQL: &str = r#"SELECT + singleton, + CASE + WHEN typeof(normalized_config_sha256) = 'blob' + AND length(normalized_config_sha256) = 32 + THEN normalized_config_sha256 + ELSE NULL + END AS normalized_config_sha256, + CASE + WHEN typeof(transport_public_key) = 'text' + AND length(CAST(transport_public_key AS BLOB)) = 64 + THEN transport_public_key + ELSE NULL + END AS transport_public_key, + CASE + WHEN typeof(user_public_key) = 'text' + AND length(CAST(user_public_key AS BLOB)) = 64 + THEN user_public_key + ELSE NULL + END AS user_public_key, + typeof(discovery_public_key) AS discovery_public_key_type, + CASE + WHEN typeof(discovery_public_key) = 'text' + AND length(CAST(discovery_public_key AS BLOB)) = 64 + THEN discovery_public_key + ELSE NULL + END AS discovery_public_key, + config_contract_version, + state_contract_version, + operator_contract_version, + status_contract_version +FROM myc_state_metadata +LIMIT 2"#; + +const INSERT_METADATA_SQL: &str = r#"INSERT INTO myc_state_metadata ( + singleton, + normalized_config_sha256, + transport_public_key, + user_public_key, + discovery_public_key, + config_contract_version, + state_contract_version, + operator_contract_version, + status_contract_version +) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)"#; + +/// Stable failure classes for typed Myc state-repository operations. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStateRepositoryErrorKind { + Binding, + Transaction, + CommitOutcomeUnknown, +} + +impl MycStateRepositoryErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Binding => "state_repository_binding_invalid", + Self::Transaction => "state_repository_transaction_failed", + Self::CommitOutcomeUnknown => "state_repository_commit_outcome_unknown", + } + } +} + +/// Source-free typed repository failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycStateRepositoryError { + kind: MycStateRepositoryErrorKind, +} + +impl MycStateRepositoryError { + const fn new(kind: MycStateRepositoryErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> MycStateRepositoryErrorKind { + self.kind + } + + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for MycStateRepositoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycStateRepositoryErrorKind::Binding => "Myc state repository binding is invalid", + MycStateRepositoryErrorKind::Transaction => "Myc state repository transaction failed", + MycStateRepositoryErrorKind::CommitOutcomeUnknown => { + "Myc state repository commit outcome is unknown" + } + }) + } +} + +impl fmt::Debug for MycStateRepositoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStateRepositoryError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycStateRepositoryError {} + +/// Borrowed typed access to one already-opened Myc state host. +/// +/// Construction is sealed to [`crate::MycStateHost::repository`]: +/// +/// ```compile_fail +/// use myc::MycStateRepository; +/// +/// let _ = MycStateRepository { host: todo!(), expected: todo!() }; +/// ``` +pub struct MycStateRepository<'host> { + host: &'host ServiceSqliteHost, + expected: &'host MycStateMetadata, +} + +impl<'host> MycStateRepository<'host> { + pub(crate) const fn new( + host: &'host ServiceSqliteHost, + expected: &'host MycStateMetadata, + ) -> Self { + Self { host, expected } + } + + /// Re-verifies the immutable Myc binding through the sealed transaction executor. + pub async fn verify_binding(&self) -> Result<(), MycStateRepositoryError> { + self.transact(false).await + } + + pub(crate) async fn bind_or_verify(&self) -> Result<(), MycStateRepositoryError> { + self.transact(true).await + } + + async fn transact(&self, initialize_missing: bool) -> Result<(), MycStateRepositoryError> { + let expected = PersistedMetadata::from(self.expected); + self.host + .transaction(move |transaction| { + Box::pin(async move { + let actual = read_metadata(transaction).await?; + match actual { + Some(actual) if actual == expected => Ok(()), + Some(_) => Err(RepositoryOperationError::Binding), + None if initialize_missing => { + insert_metadata(transaction, &expected).await?; + match read_metadata(transaction).await? { + Some(actual) if actual == expected => Ok(()), + Some(_) | None => Err(RepositoryOperationError::Binding), + } + } + None => Err(RepositoryOperationError::Binding), + } + }) + }) + .await + .map_err(map_transaction_error) + } +} + +impl fmt::Debug for MycStateRepository<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStateRepository") + .field("state", &"[sealed]") + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +struct PersistedMetadata { + normalized_config_sha256: [u8; 32], + transport_public_key: Box<str>, + user_public_key: Box<str>, + discovery_public_key: Option<Box<str>>, + config_contract_version: u32, + state_contract_version: u32, + operator_contract_version: u32, + status_contract_version: u32, +} + +impl From<&MycStateMetadata> for PersistedMetadata { + fn from(metadata: &MycStateMetadata) -> Self { + let identities = metadata.expected_identities(); + let versions = metadata.policy_versions(); + Self { + normalized_config_sha256: *metadata.configuration_digest().as_bytes(), + transport_public_key: identities.transport().as_hex().into(), + user_public_key: identities.user().as_hex().into(), + discovery_public_key: identities.discovery().map(|value| value.as_hex().into()), + config_contract_version: versions.configuration(), + state_contract_version: versions.state(), + operator_contract_version: versions.operator(), + status_contract_version: versions.status(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RepositoryOperationError { + Binding, + Storage, +} + +async fn read_metadata( + transaction: &mut ServiceSqliteTransaction<'_>, +) -> Result<Option<PersistedMetadata>, RepositoryOperationError> { + let rows = sqlx::query(READ_METADATA_SQL) + .fetch_all(&mut *transaction) + .await + .map_err(|_| RepositoryOperationError::Storage)?; + if rows.len() > 1 { + return Err(RepositoryOperationError::Binding); + } + let Some(row) = rows.first() else { + return Ok(None); + }; + let singleton = row + .try_get::<i64, _>("singleton") + .map_err(|_| RepositoryOperationError::Binding)?; + let normalized = row + .try_get::<Option<Vec<u8>>, _>("normalized_config_sha256") + .map_err(|_| RepositoryOperationError::Binding)? + .ok_or(RepositoryOperationError::Binding)?; + let normalized_config_sha256 = normalized + .try_into() + .map_err(|_| RepositoryOperationError::Binding)?; + let transport_public_key = bounded_public_key(row, "transport_public_key")?; + let user_public_key = bounded_public_key(row, "user_public_key")?; + let discovery_type = row + .try_get::<&str, _>("discovery_public_key_type") + .map_err(|_| RepositoryOperationError::Binding)?; + let discovery_public_key = match discovery_type { + "null" => None, + "text" => Some(bounded_public_key(row, "discovery_public_key")?), + _ => return Err(RepositoryOperationError::Binding), + }; + let actual = PersistedMetadata { + normalized_config_sha256, + transport_public_key, + user_public_key, + discovery_public_key, + config_contract_version: bounded_version(row, "config_contract_version")?, + state_contract_version: bounded_version(row, "state_contract_version")?, + operator_contract_version: bounded_version(row, "operator_contract_version")?, + status_contract_version: bounded_version(row, "status_contract_version")?, + }; + (singleton == 1) + .then_some(Some(actual)) + .ok_or(RepositoryOperationError::Binding) +} + +fn bounded_public_key( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<Box<str>, RepositoryOperationError> { + let value = row + .try_get::<Option<String>, _>(column) + .map_err(|_| RepositoryOperationError::Binding)? + .ok_or(RepositoryOperationError::Binding)?; + let valid = value.len() == 64 + && value.as_bytes().iter().all(u8::is_ascii_hexdigit) + && !value.as_bytes().iter().any(u8::is_ascii_uppercase); + valid + .then(|| value.into_boxed_str()) + .ok_or(RepositoryOperationError::Binding) +} + +fn bounded_version( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<u32, RepositoryOperationError> { + let value = row + .try_get::<i64, _>(column) + .map_err(|_| RepositoryOperationError::Binding)?; + u32::try_from(value) + .ok() + .filter(|value| *value != 0) + .ok_or(RepositoryOperationError::Binding) +} + +async fn insert_metadata( + transaction: &mut ServiceSqliteTransaction<'_>, + expected: &PersistedMetadata, +) -> Result<(), RepositoryOperationError> { + let result = sqlx::query(INSERT_METADATA_SQL) + .bind(expected.normalized_config_sha256.as_slice()) + .bind(expected.transport_public_key.as_ref()) + .bind(expected.user_public_key.as_ref()) + .bind(expected.discovery_public_key.as_deref()) + .bind(i64::from(expected.config_contract_version)) + .bind(i64::from(expected.state_contract_version)) + .bind(i64::from(expected.operator_contract_version)) + .bind(i64::from(expected.status_contract_version)) + .execute(&mut *transaction) + .await + .map_err(|_| RepositoryOperationError::Storage)?; + (result.rows_affected() == 1) + .then_some(()) + .ok_or(RepositoryOperationError::Storage) +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<RepositoryOperationError>, +) -> MycStateRepositoryError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return MycStateRepositoryError::new(MycStateRepositoryErrorKind::CommitOutcomeUnknown); + } + let kind = match error.operation_error() { + Some(RepositoryOperationError::Binding) => MycStateRepositoryErrorKind::Binding, + Some(RepositoryOperationError::Storage) | None => MycStateRepositoryErrorKind::Transaction, + }; + MycStateRepositoryError::new(kind) +} diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -3,14 +3,16 @@ use std::error::Error; use myc::{ - MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_SCHEMA_CATALOG_SHA256, MYC_STATE_SCHEMA_VERSION, - MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_1_SHA256, + MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_CATALOG_SHA256, + 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, }; use radroots_service_sqlite::{ - MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaObject, - SchemaObjectKind, SchemaVersionCatalog, + MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest, + SchemaObject, SchemaObjectKind, SchemaVersionCatalog, }; const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs"); @@ -18,45 +20,69 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_and_empty_migration_catalog_have_exact_literal_identities() { +fn schema_v1_v2_and_single_migration_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_SCHEMA_VERSION, 1); - assert!(migrations.descriptors().is_empty()); - assert_eq!(migrations.current_version(), 1); + 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!( + migration.checksum().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256 + ); + assert_eq!(migrations.current_version(), 2); assert_eq!( migrations.digest().as_bytes(), &MYC_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 1); - let version = schema.versions()[0]; - assert_eq!(version.version(), 1); + assert_eq!(schema.versions().len(), 2); + assert_eq!(schema.versions()[0].version(), 1); assert_eq!( - version.object_count(), + schema.versions()[0].object_count(), MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT ); - assert_eq!(version.object_count(), 6); assert_eq!( - version.digest().as_bytes(), + schema.versions()[0].digest().as_bytes(), &MYC_STATE_SCHEMA_VERSION_1_SHA256 ); + assert_eq!(schema.versions()[1].version(), 2); + assert_eq!( + schema.versions()[1].object_count(), + MYC_STATE_SCHEMA_VERSION_2_OBJECT_COUNT + ); + assert_eq!(schema.versions()[1].object_count(), 9); + assert_eq!( + schema.versions()[1].digest().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_2_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"); assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256), + "c5eb978bda1bc70c70bd9bd44d8cba70cb2857d26a9dce74961f4b781e710037" + ); + assert_eq!( hex::encode(MYC_MIGRATION_CATALOG_SHA256), - "ec89dc8f7b6c2a11b967e33808e4031e29b3970ffee4959bff9bad352877ee9b" + "be15584e4e6fe1f5b80209e8f6125ecc9281fc22e9769a79f210c30c431ff462" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256), "94dc66fbca601679615c055229dc0db6119f5bd92b04390c67f698a036fa78ae" ); assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_2_SHA256), + "94739b5a34ca8ed130b946d092731b59bf1548fe541d9a9269a33d0aed1ea09e" + ); + assert_eq!( hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256), - "2309153f3b49754887c548a7459b3e09099c60f7146b373c8f96706c6768d791" + "673f8ba2095ee02e8048af850d294436cb7b8150e91693b7727fae05812e831c" ); } @@ -75,7 +101,13 @@ fn independent_validator_rejects_migration_or_schema_drift() { MycStateCatalogErrorKind::CatalogMismatch ); - let empty_migrations = myc_migration_catalog().expect("empty migrations"); + let expected_migrations = myc_migration_catalog().expect("expected migrations"); + let v1 = SchemaVersionCatalog::new( + 1, + [], + SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_1_SHA256), + ) + .expect("schema v1"); let object_digest = SchemaObject::computed_digest(SchemaObjectKind::Table, "unexpected", "unexpected", SQL) .expect("object digest"); @@ -88,11 +120,11 @@ fn independent_validator_rejects_migration_or_schema_drift() { ) .expect("schema object"); let snapshot_digest = - SchemaVersionCatalog::computed_digest(1, [object.clone()]).expect("snapshot digest"); - let version = SchemaVersionCatalog::new(1, [object], snapshot_digest).expect("version"); - let schema = SchemaCatalog::new(&empty_migrations, [version]).expect("drift schema catalog"); + 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"); assert_eq!( - validate_myc_state_catalogs(&empty_migrations, &schema) + validate_myc_state_catalogs(&expected_migrations, &schema) .expect_err("schema drift") .kind(), MycStateCatalogErrorKind::CatalogMismatch @@ -101,34 +133,20 @@ fn independent_validator_rejects_migration_or_schema_drift() { #[test] fn catalog_errors_are_stable_source_free_and_redacted() { - let migrations = myc_migration_catalog().expect("migration catalog"); - let object_digest = SchemaObject::computed_digest( - SchemaObjectKind::Table, - "secret_table", - "secret_table", - "secret SQL text", - ) - .expect("object digest"); - let object = SchemaObject::new( - SchemaObjectKind::Table, - "secret_table", - "secret_table", - "secret SQL text", - object_digest, - ) - .expect("object"); - let snapshot = - SchemaVersionCatalog::computed_digest(1, [object.clone()]).expect("snapshot digest"); - let version = SchemaVersionCatalog::new(1, [object], snapshot).expect("version"); - let schema = SchemaCatalog::new(&migrations, [version]).expect("schema catalog"); - let error = validate_myc_state_catalogs(&migrations, &schema).expect_err("mismatch"); + const SQL: &str = "CREATE TABLE secret_table (value INTEGER NOT NULL) STRICT"; + let migration = + MigrationDescriptor::sql(2, "secret_schema", SQL, MigrationChecksum::for_sql(SQL)) + .expect("migration"); + let migrations = MigrationCatalog::new([migration]).expect("migration catalog"); + let expected_schema = myc_schema_catalog().expect("expected schema"); + let error = validate_myc_state_catalogs(&migrations, &expected_schema).expect_err("mismatch"); assert_eq!(error.kind(), MycStateCatalogErrorKind::CatalogMismatch); assert_eq!(error.code(), "state_catalog_mismatch"); assert!(Error::source(&error).is_none()); let rendered = format!("{error} {error:?}"); assert!(!rendered.contains("secret")); - assert!(!rendered.contains(&hex::encode(snapshot.as_bytes()))); + assert!(!rendered.contains(&hex::encode(MigrationChecksum::for_sql(SQL).as_bytes()))); } #[test] @@ -138,20 +156,19 @@ fn catalog_source_is_pure_pinned_and_uses_only_the_shared_authority() { )); assert!(LIB_SOURCE.contains("mod state_catalog;")); assert!(!LIB_SOURCE.contains("pub mod state_catalog;")); - assert!(CATALOG_SOURCE.contains("MigrationCatalog::new([])")); + assert!(CATALOG_SOURCE.contains("MigrationDescriptor::sql(")); + assert!(CATALOG_SOURCE.contains("MigrationChecksum::from_bytes(")); assert!(CATALOG_SOURCE.contains("SchemaDigest::from_bytes(")); assert!(!CATALOG_SOURCE.contains("computed_digest")); for forbidden in [ "sqlx::", "rusqlite", "libsqlite3_sys", - "CREATE TABLE", "raw_sql", "std::fs", "std::path", - "Connection", + "SqliteConnection", "Transaction", - "MigrationDescriptor", ] { assert!( !CATALOG_SOURCE.contains(forbidden), diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs @@ -65,7 +65,7 @@ fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentit "test-target", "service-host", 1, - 1, + myc::MYC_STATE_SCHEMA_VERSION, 1, 1, 1, @@ -74,6 +74,23 @@ fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentit (applied_at, build) } +fn mismatched_migration_build() -> MigrationBuildIdentity { + MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "b44119fbac5985be8127ad1bf56d2950e6399427", + "rustc-test", + "test-target", + "service-host", + 1, + 1, + 1, + 1, + 1, + ) + .expect("structurally valid mismatched build") +} + #[tokio::test] async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly() { let directory = tempfile::tempdir().expect("temporary root"); @@ -84,7 +101,8 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( let lock = runtime.artifacts().state_lock(); assert!(!state.exists()); - initialize_myc_state(&runtime, &metadata) + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) .await .expect("create-new initialization"); assert!(state.is_file()); @@ -98,12 +116,11 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( 0o600 ); - let duplicate = initialize_myc_state(&runtime, &metadata) + let duplicate = initialize_myc_state(&runtime, &metadata, applied_at, &build) .await .expect_err("second initialization must fail"); assert_eq!(duplicate.kind(), MycStateHostErrorKind::Initialize); - let (applied_at, build) = migration_evidence(); let writer = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) .await .expect("existing writable state"); @@ -147,7 +164,18 @@ async fn missing_state_and_mismatched_evidence_fail_before_database_creation() { assert_eq!(missing.kind(), MycStateHostErrorKind::ReadWriteOpen); assert!(!primary.artifacts().state_database().exists()); - let mismatch = initialize_myc_state(&secondary, &primary_metadata) + let invalid_build = initialize_myc_state( + &primary, + &primary_metadata, + applied_at, + &mismatched_migration_build(), + ) + .await + .expect_err("migration build contract mismatch"); + assert_eq!(invalid_build.kind(), MycStateHostErrorKind::InvalidEvidence); + assert!(!primary.artifacts().state_database().exists()); + + let mismatch = initialize_myc_state(&secondary, &primary_metadata, applied_at, &build) .await .expect_err("cross-instance metadata"); assert_eq!(mismatch.kind(), MycStateHostErrorKind::InvalidEvidence); diff --git a/tests/services_hardening_state_metadata.rs b/tests/services_hardening_state_metadata.rs @@ -4,9 +4,10 @@ use std::{error::Error, path::Path}; use myc::{ MYC_CONFIG_SCHEMA_VERSION, MYC_OPERATOR_CONTRACT_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, - MYC_STATE_APPLICATION_ID, MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycStateMetadata, - MycStateMetadataErrorKind, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, - parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context, + MYC_STATE_APPLICATION_ID, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, + MycConfigProfile, MycStateMetadata, MycStateMetadataErrorKind, RadrootsHostEnvironment, + RadrootsPathResolver, RadrootsPlatform, parse_myc_cli_v1_from, parse_myc_config_v1, + resolve_myc_runtime_context, }; use radroots_storage::event::SourceGeneration; @@ -53,7 +54,7 @@ fn exact_database_configuration_identity_and_policy_bindings_are_frozen() { let directory = tempfile::tempdir().expect("temporary root"); let runtime = runtime(directory.path(), "repo-local"); let metadata = state_metadata(&runtime, EXAMPLE).expect("state metadata"); - let database = metadata.database(); + let database = metadata.initial_database_metadata(); assert_eq!(MYC_STATE_APPLICATION_ID.to_be_bytes(), *b"RDMY"); assert_eq!(database.application_id().get(), MYC_STATE_APPLICATION_ID); @@ -62,9 +63,16 @@ fn exact_database_configuration_identity_and_policy_bindings_are_frozen() { assert_eq!(database.source_generation().as_bytes(), &[0x5a; 32]); assert_eq!( database.state_schema_version().get(), - MYC_STATE_SCHEMA_VERSION + MYC_STATE_BASE_SCHEMA_VERSION ); assert_eq!(database.created_at_unix_ms(), 1_725_000_000_000); + assert_eq!( + metadata + .database_identity() + .supported_state_schema_version() + .get(), + MYC_STATE_SCHEMA_VERSION + ); let identities = metadata.expected_identities(); assert_eq!(identities.transport().as_hex(), "4".repeat(64)); diff --git a/tests/services_hardening_state_repository.rs b/tests/services_hardening_state_repository.rs @@ -0,0 +1,283 @@ +#![forbid(unsafe_code)] + +use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path}; + +use myc::{ + MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycStateHostErrorKind, MycStateMetadata, + MycStateRepositoryErrorKind, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, + initialize_myc_state, open_myc_state_inspection, open_myc_state_read_write, + parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context, +}; +use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; +use radroots_storage::event::SourceGeneration; +use sqlx::{ConnectOptions, Connection, Row, sqlite::SqliteConnectOptions}; + +const CONFIG_EXAMPLE: &[u8] = + include_bytes!("../contracts/services_hardening/config.v1.example.toml"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const HOST_SOURCE: &str = include_str!("../src/state_host.rs"); +const REPOSITORY_SOURCE: &str = include_str!("../src/state_repository.rs"); + +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, source: &[u8]) -> MycStateMetadata { + let configuration = + parse_myc_config_v1(source, 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) +} + +#[tokio::test] +async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime, CONFIG_EXAMPLE); + let (applied_at, build) = migration_evidence(); + + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("initialized current state"); + let inspection = open_myc_state_inspection(&runtime, &metadata) + .await + .expect("current inspection"); + inspection + .repository() + .verify_binding() + .await + .expect("typed repository verification"); + assert_eq!( + format!("{:?}", inspection.repository()), + "MycStateRepository { state: \"[sealed]\" }" + ); + inspection.close().await.expect("inspection 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"); + let row = sqlx::query( + "SELECT state_schema_version FROM radroots_service_metadata WHERE singleton = 1", + ) + .fetch_one(&mut connection) + .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) + .await + .expect("migration row"); + assert_eq!(migration.get::<i64, _>(0), 2); + assert_eq!(migration.get::<String, _>(1), "create_myc_state_metadata"); + let binding = sqlx::query( + "SELECT normalized_config_sha256, transport_public_key, user_public_key, \ + discovery_public_key, config_contract_version, state_contract_version, \ + operator_contract_version, status_contract_version \ + FROM myc_state_metadata WHERE singleton = 1", + ) + .fetch_one(&mut connection) + .await + .expect("Myc metadata row"); + assert_eq!( + binding.get::<Vec<u8>, _>(0), + metadata.configuration_digest().as_bytes() + ); + assert_eq!( + binding.get::<String, _>(1), + metadata.expected_identities().transport().as_hex() + ); + assert_eq!( + binding.get::<String, _>(2), + metadata.expected_identities().user().as_hex() + ); + assert_eq!( + binding.get::<String, _>(3), + metadata + .expected_identities() + .discovery() + .expect("discovery identity") + .as_hex() + ); + assert_eq!(binding.get::<i64, _>(4), 1); + assert_eq!(binding.get::<i64, _>(5), 2); + assert_eq!(binding.get::<i64, _>(6), 1); + assert_eq!(binding.get::<i64, _>(7), 1); + connection.close().await.expect("test connection close"); +} + +#[tokio::test] +async fn exact_binding_is_idempotent_and_conflicting_configuration_fails_closed() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + let expected = metadata(&runtime, CONFIG_EXAMPLE); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &expected, applied_at, &build) + .await + .expect("initialization"); + + let writer = open_myc_state_read_write(&runtime, &expected, applied_at, &build) + .await + .expect("idempotent exact open"); + writer + .repository() + .verify_binding() + .await + .expect("exact binding"); + writer.close().await.expect("writer close"); + + let changed_source = String::from_utf8(CONFIG_EXAMPLE.to_vec()) + .expect("UTF-8 config") + .replace("level = \"info\"", "level = \"warn\""); + let changed = metadata(&runtime, changed_source.as_bytes()); + let error = open_myc_state_read_write(&runtime, &changed, applied_at, &build) + .await + .expect_err("changed normalized binding"); + assert_eq!(error.kind(), MycStateHostErrorKind::Repository); + + let inspection = open_myc_state_inspection(&runtime, &expected) + .await + .expect("original binding remains authoritative"); + inspection.close().await.expect("inspection close"); +} + +#[tokio::test] +async fn database_guards_reject_metadata_update_and_delete() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime, CONFIG_EXAMPLE); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("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("test connection"); + assert!( + sqlx::query("UPDATE myc_state_metadata SET status_contract_version = 2") + .execute(&mut connection) + .await + .is_err() + ); + assert!( + sqlx::query("DELETE FROM myc_state_metadata") + .execute(&mut connection) + .await + .is_err() + ); + connection.close().await.expect("test connection close"); +} + +#[tokio::test] +async fn repository_boundary_is_sealed_typed_redacted_and_network_free() { + assert!(LIB_SOURCE.contains("mod state_repository;")); + assert!(!LIB_SOURCE.contains("pub mod state_repository;")); + assert!(HOST_SOURCE.contains("pub const fn repository(&self) -> MycStateRepository<'_>")); + assert!(REPOSITORY_SOURCE.contains(".transaction(move |transaction|")); + assert!(REPOSITORY_SOURCE.contains("INSERT INTO myc_state_metadata")); + for forbidden in [ + "SqliteConnection", + "SqlitePool", + "PoolConnection", + "BEGIN ", + "COMMIT", + "ROLLBACK", + "provider", + "relay", + "reqwest", + "nostr::", + "std::fs", + "std::path", + "std::env", + "std::time", + ] { + assert!( + !REPOSITORY_SOURCE.contains(forbidden), + "found forbidden repository authority `{forbidden}`" + ); + } + + let kind = myc::MycStateRepositoryErrorKind::Binding; + assert_eq!(kind.code(), "state_repository_binding_invalid"); + let rendered = format!("{kind:?}"); + assert!(!rendered.contains("state.sqlite")); + + let directory = tempfile::tempdir().expect("temporary root"); + let context = runtime(directory.path()); + prepare_state_directory(&context); + let metadata = metadata(&context, CONFIG_EXAMPLE); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&context, &metadata, applied_at, &build) + .await + .expect("initialization"); + let inspection = open_myc_state_inspection(&context, &metadata) + .await + .expect("inspection"); + inspection.close().await.expect("inspection close"); + let error = inspection + .repository() + .verify_binding() + .await + .expect_err("closed host rejects repository admission"); + assert_eq!(error.kind(), MycStateRepositoryErrorKind::Transaction); + assert!(Error::source(&error).is_none()); + assert!(!format!("{error} {error:?}").contains("secret")); +}