myc

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

commit 7ffa05d3f71ac35fec30302db26d2beac87982c2
parent 5704286ef326288a0ef8f4aaca53f8dcc3682c56
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 13:30:28 +0000

state: freeze Myc SQLite catalogs

Diffstat:
MCargo.lock | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 1+
Mradroots.lib.source-lock.v1.toml | 2+-
Msrc/lib.rs | 7+++++++
Asrc/state_catalog.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 7+++++++
Atests/services_hardening_state_catalog.rs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 434 insertions(+), 3 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -798,6 +798,16 @@ dependencies = [ ] [[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] name = "futures" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -805,6 +815,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1411,9 +1422,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -1533,6 +1544,7 @@ dependencies = [ "radroots_nostr_connect", "radroots_runtime_paths", "radroots_secrets", + "radroots_service_sqlite", "radroots_signing", "rand 0.9.2", "serde", @@ -2073,6 +2085,24 @@ dependencies = [ ] [[package]] +name = "radroots_service_sqlite" +version = "0.1.0-alpha" +source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" +dependencies = [ + "fs2", + "futures", + "libsqlite3-sys", + "radroots_runtime_paths", + "radroots_storage", + "rustix", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", +] + +[[package]] name = "radroots_signing" version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" @@ -2087,6 +2117,40 @@ dependencies = [ ] [[package]] +name = "radroots_storage" +version = "0.1.0-alpha" +source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" +dependencies = [ + "radroots_event", + "radroots_event_codec", + "radroots_protocol", + "radroots_trade", + "radroots_transport", + "sha2", +] + +[[package]] +name = "radroots_trade" +version = "0.1.0-alpha" +source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" +dependencies = [ + "radroots_core", + "radroots_event", + "radroots_identity", +] + +[[package]] +name = "radroots_transport" +version = "0.1.0-alpha" +source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427" +dependencies = [ + "radroots_event", + "radroots_identity", + "radroots_protocol", + "sha2", +] + +[[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2605,6 +2669,8 @@ dependencies = [ "sha2", "smallvec", "thiserror 2.0.18", + "tokio", + "tokio-stream", "tracing", "url", ] @@ -2641,6 +2707,7 @@ dependencies = [ "sqlx-core", "sqlx-sqlite", "syn 2.0.117", + "tokio", "url", ] @@ -2914,6 +2981,17 @@ dependencies = [ ] [[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] name = "tokio-tungstenite" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3377,6 +3455,28 @@ dependencies = [ ] [[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -44,6 +44,7 @@ radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "b44119fba radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["events"] } radroots_nostr_connect = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } +radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std", "keyring"] } radroots_signing = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] } serde = { version = "1.0", features = ["derive"] } diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml @@ -6,4 +6,4 @@ workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e version = "0.1.0-alpha" source_archive_sha256 = "975474804e6358b9228981add0a23181dbdd1afddf5ae12579c82220876bc379" lockfile = "Cargo.lock" -lockfile_sha256 = "25d0049df23e822284b368707f08d08ddcfac85c30703d7154baf80b9baadf3e" +lockfile_sha256 = "d546a6b89f6cf896d410da6b45b48454594cef208ac85f93892289d49eb04035" diff --git a/src/lib.rs b/src/lib.rs @@ -25,6 +25,7 @@ mod runtime_context; pub mod signer; mod signing_adapter; pub mod sql; +mod state_catalog; pub mod transport; pub use app::{ @@ -104,4 +105,10 @@ pub use runtime_context::{ MycRuntimeContext, MycRuntimeContextError, MycRuntimeContextErrorKind, 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, + MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, + validate_myc_state_catalogs, +}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/src/state_catalog.rs b/src/state_catalog.rs @@ -0,0 +1,155 @@ +//! Immutable Myc schema and migration catalog identity. + +use core::fmt; +use std::error::Error; + +use radroots_service_sqlite::{ + MigrationCatalog, SchemaCatalog, SchemaDigest, SchemaVersionCatalog, +}; + +/// The clean-slate Myc baseline schema version. +pub const MYC_STATE_SCHEMA_VERSION: u32 = 1; + +/// 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, +]; + +/// 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, + 0x11, 0x9f, 0x5b, 0xd9, 0x2b, 0x04, 0x39, 0x0c, 0x67, 0xf6, 0x98, 0xa0, 0x36, 0xfa, 0x78, 0xae, +]; + +/// 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, +]; + +/// Stable classes for invalid embedded Myc catalog definitions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStateCatalogErrorKind { + MigrationCatalog, + SchemaCatalog, + CatalogMismatch, +} + +impl MycStateCatalogErrorKind { + /// Returns the stable machine-readable classification. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::MigrationCatalog => "migration_catalog_invalid", + Self::SchemaCatalog => "schema_catalog_invalid", + Self::CatalogMismatch => "state_catalog_mismatch", + } + } +} + +/// Source-free failure to construct or validate the embedded Myc catalogs. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycStateCatalogError { + kind: MycStateCatalogErrorKind, +} + +impl MycStateCatalogError { + const fn new(kind: MycStateCatalogErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> MycStateCatalogErrorKind { + self.kind + } + + /// Returns the stable machine-readable code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for MycStateCatalogError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycStateCatalogErrorKind::MigrationCatalog => { + "Myc migration catalog definition is invalid" + } + MycStateCatalogErrorKind::SchemaCatalog => "Myc schema catalog definition is invalid", + MycStateCatalogErrorKind::CatalogMismatch => { + "Myc state catalogs do not match the governed identity" + } + }) + } +} + +impl fmt::Debug for MycStateCatalogError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStateCatalogError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycStateCatalogError {} + +/// Constructs the exact schema-v1 migration catalog. +pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> { + let catalog = MigrationCatalog::new([]) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; + if catalog.current_version() != MYC_STATE_SCHEMA_VERSION + || !catalog.descriptors().is_empty() + || catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256 + { + return Err(MycStateCatalogError::new( + MycStateCatalogErrorKind::CatalogMismatch, + )); + } + Ok(catalog) +} + +/// 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, + [], + SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_1_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + let catalog = SchemaCatalog::new(&migrations, [version]) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + validate_myc_state_catalogs(&migrations, &catalog)?; + Ok(catalog) +} + +/// 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 valid = migrations.current_version() == MYC_STATE_SCHEMA_VERSION + && migrations.descriptors().is_empty() + && 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[0].object_count() == MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT + && versions[0].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_1_SHA256 + && schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256; + if valid { + Ok(()) + } else { + Err(MycStateCatalogError::new( + MycStateCatalogErrorKind::CatalogMismatch, + )) + } +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -34,6 +34,13 @@ fn shared_runtime_paths_is_exactly_pinned_to_the_source_locked_lib() { } #[test] +fn shared_service_sqlite_is_exactly_pinned_to_the_source_locked_lib() { + assert!(MANIFEST.contains( + "radroots_service_sqlite = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }" + )); +} + +#[test] fn release_acceptance_checks_both_feature_profiles() { assert!( RELEASE_ACCEPTANCE.contains("cargo check --locked --all-targets --no-default-features\n") diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -0,0 +1,161 @@ +#![forbid(unsafe_code)] + +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, + MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, + validate_myc_state_catalogs, +}; +use radroots_service_sqlite::{ + MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaObject, + SchemaObjectKind, SchemaVersionCatalog, +}; + +const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs"); +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() { + 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!( + 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!( + version.object_count(), + MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT + ); + assert_eq!(version.object_count(), 6); + assert_eq!( + version.digest().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_1_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_MIGRATION_CATALOG_SHA256), + "ec89dc8f7b6c2a11b967e33808e4031e29b3970ffee4959bff9bad352877ee9b" + ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256), + "94dc66fbca601679615c055229dc0db6119f5bd92b04390c67f698a036fa78ae" + ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256), + "2309153f3b49754887c548a7459b3e09099c60f7146b373c8f96706c6768d791" + ); +} + +#[test] +fn independent_validator_rejects_migration_or_schema_drift() { + const SQL: &str = "CREATE TABLE unexpected (value INTEGER NOT NULL) STRICT"; + let migration = + MigrationDescriptor::sql(2, "unexpected_schema", SQL, MigrationChecksum::for_sql(SQL)) + .expect("valid drift fixture"); + let migrations = MigrationCatalog::new([migration]).expect("drift migration catalog"); + let expected_schema = myc_schema_catalog().expect("expected schema"); + assert_eq!( + validate_myc_state_catalogs(&migrations, &expected_schema) + .expect_err("migration drift") + .kind(), + MycStateCatalogErrorKind::CatalogMismatch + ); + + let empty_migrations = myc_migration_catalog().expect("empty migrations"); + let object_digest = + SchemaObject::computed_digest(SchemaObjectKind::Table, "unexpected", "unexpected", SQL) + .expect("object digest"); + let object = SchemaObject::new( + SchemaObjectKind::Table, + "unexpected", + "unexpected", + SQL, + object_digest, + ) + .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"); + assert_eq!( + validate_myc_state_catalogs(&empty_migrations, &schema) + .expect_err("schema drift") + .kind(), + MycStateCatalogErrorKind::CatalogMismatch + ); +} + +#[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"); + + 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()))); +} + +#[test] +fn catalog_source_is_pure_pinned_and_uses_only_the_shared_authority() { + assert!(MANIFEST.contains( + "radroots_service_sqlite = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }" + )); + 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("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", + "Transaction", + "MigrationDescriptor", + ] { + assert!( + !CATALOG_SOURCE.contains(forbidden), + "found forbidden catalog authority `{forbidden}`" + ); + } +}