myc

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

commit 681277a35eb026989eb68fd5c7defbd36a30e4c9
parent 7ffa05d3f71ac35fec30302db26d2beac87982c2
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 13:43:03 +0000

myc: seal SQLite host lifecycle

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mradroots.lib.source-lock.v1.toml | 2+-
Msrc/lib.rs | 5+++++
Asrc/state_host.rs | 313+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 7+++++++
Atests/services_hardening_state_host.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 515 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1546,6 +1546,7 @@ dependencies = [ "radroots_secrets", "radroots_service_sqlite", "radroots_signing", + "radroots_storage", "rand 0.9.2", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml @@ -65,5 +65,6 @@ zeroize = "1.8" [dev-dependencies] futures-util = "0.3.32" +radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", default-features = false } serial_test = "3" tokio-tungstenite = "0.26.2" 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 = "d546a6b89f6cf896d410da6b45b48454594cef208ac85f93892289d49eb04035" +lockfile_sha256 = "8dfe6eb163aafac545e3e23110f4ccd0822a41dbbac8df441a5cedc85e75cce4" diff --git a/src/lib.rs b/src/lib.rs @@ -26,6 +26,7 @@ pub mod signer; mod signing_adapter; pub mod sql; mod state_catalog; +mod state_host; pub mod transport; pub use app::{ @@ -111,4 +112,8 @@ pub use state_catalog::{ MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs, }; +pub use state_host::{ + MycStateHost, MycStateHostError, MycStateHostErrorKind, MycStateHostMode, initialize_myc_state, + open_myc_state_inspection, open_myc_state_read_write, +}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/src/state_host.rs b/src/state_host.rs @@ -0,0 +1,313 @@ +//! Sealed lifecycle boundary for the canonical Myc SQLite state catalog. + +use core::fmt; +use std::{error::Error, path::PathBuf}; + +use radroots_service_sqlite::{ + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceDatabaseIdentity, + ServiceDatabaseMetadata, ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, + initialize_database, +}; +use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions}; + +use crate::{ + MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, myc_migration_catalog, myc_schema_catalog, + validate_myc_state_catalogs, +}; + +/// Stable lifecycle mode of one opened Myc state host. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStateHostMode { + ReadWriteExisting, + ReadOnlyInspection, +} + +/// Stable source-free class for a Myc state-host lifecycle failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStateHostErrorKind { + InvalidPaths, + InvalidEvidence, + Catalog, + Initialize, + ReadWriteOpen, + InspectionOpen, + Close, +} + +impl MycStateHostErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidPaths => "state_paths_invalid", + Self::InvalidEvidence => "state_evidence_invalid", + Self::Catalog => "state_catalog_invalid", + Self::Initialize => "state_initialize_failed", + Self::ReadWriteOpen => "state_read_write_open_failed", + Self::InspectionOpen => "state_inspection_open_failed", + Self::Close => "state_close_failed", + } + } +} + +/// Redacted Myc state-host lifecycle failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycStateHostError { + kind: MycStateHostErrorKind, +} + +impl MycStateHostError { + const fn new(kind: MycStateHostErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> MycStateHostErrorKind { + 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 MycStateHostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycStateHostErrorKind::InvalidPaths => "Myc state paths are invalid", + MycStateHostErrorKind::InvalidEvidence => "Myc state identity evidence is invalid", + MycStateHostErrorKind::Catalog => "Myc state catalogs are invalid", + 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::Close => "Myc state host could not be closed", + }) + } +} + +impl fmt::Debug for MycStateHostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStateHostError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycStateHostError {} + +/// One opened Myc state catalog whose raw SQLite authority remains sealed. +/// +/// Callers cannot construct the wrapper or extract the shared host: +/// +/// ```compile_fail +/// use myc::{MycStateHost, MycStateHostMode}; +/// +/// let _ = MycStateHost { +/// host: todo!(), +/// mode: MycStateHostMode::ReadWriteExisting, +/// }; +/// ``` +/// +/// The wrapper intentionally exposes no transaction or connection escape: +/// +/// ```compile_fail +/// use myc::MycStateHost; +/// +/// fn bypass(host: &MycStateHost) { +/// let _ = host.transaction(|_| async { Ok::<_, ()>(()) }); +/// } +/// ``` +pub struct MycStateHost { + host: ServiceSqliteHost, + mode: MycStateHostMode, +} + +impl MycStateHost { + /// Returns the lifecycle mode selected when this host was opened. + #[must_use] + pub const fn mode(&self) -> MycStateHostMode { + self.mode + } + + /// Drains the shared host and explicitly releases retained authority. + pub async fn close(&self) -> Result<(), MycStateHostError> { + self.host + .close() + .await + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Close)) + } +} + +impl fmt::Debug for MycStateHost { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStateHost") + .field("mode", &self.mode) + .field("state", &"[sealed]") + .finish() + } +} + +/// Creates a missing Myc catalog exactly once and releases initialization authority. +/// +/// This function never opens an existing database as initialization. The caller +/// injects the shared metadata evidence; the Myc metadata-binding layer owns +/// its exact application and configuration bindings. +pub async fn initialize_myc_state( + runtime: &MycRuntimeContext, + metadata: &ServiceDatabaseMetadata, +) -> Result<(), MycStateHostError> { + let paths = state_paths(runtime)?; + require_metadata(runtime, metadata)?; + let (migrations, schema) = catalogs()?; + let mut authority = initialize_database( + &paths, + OpenMode::Initialize, + metadata, + &schema, + initialize_empty_catalog, + ) + .await + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?; + authority + .release() + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?; + drop(migrations); + Ok(()) +} + +/// Opens an already initialized Myc catalog with exclusive writer authority. +/// +/// Missing state is never created. Migration time and build identity remain +/// explicit injected evidence even while the baseline migration catalog is +/// empty. +pub async fn open_myc_state_read_write( + runtime: &MycRuntimeContext, + identity: &ServiceDatabaseIdentity, + applied_at: MigrationAppliedAtUnixSeconds, + build: &MigrationBuildIdentity, +) -> Result<MycStateHost, MycStateHostError> { + let paths = state_paths(runtime)?; + require_identity(runtime, identity)?; + let (migrations, schema) = catalogs()?; + let (host, outcome) = ServiceSqliteHost::open_read_write_existing( + &paths, + identity, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + applied_at, + build, + &[], + ) + .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 + { + let _ = host.close().await; + return Err(MycStateHostError::new(MycStateHostErrorKind::Catalog)); + } + Ok(MycStateHost { + host, + mode: MycStateHostMode::ReadWriteExisting, + }) +} + +/// Opens an already initialized Myc catalog for immutable inspection. +pub async fn open_myc_state_inspection( + runtime: &MycRuntimeContext, + identity: &ServiceDatabaseIdentity, +) -> Result<MycStateHost, MycStateHostError> { + let paths = state_paths(runtime)?; + require_identity(runtime, identity)?; + let (migrations, schema) = catalogs()?; + let host = ServiceSqliteHost::open_read_only_inspection( + &paths, + identity, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + ) + .await + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?; + Ok(MycStateHost { + host, + mode: MycStateHostMode::ReadOnlyInspection, + }) +} + +fn state_paths(runtime: &MycRuntimeContext) -> Result<ServiceSqlitePaths, MycStateHostError> { + ServiceSqlitePaths::from_runtime_context(runtime.context()) + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidPaths)) +} + +fn require_metadata( + runtime: &MycRuntimeContext, + metadata: &ServiceDatabaseMetadata, +) -> Result<(), MycStateHostError> { + let matches = metadata.service() == runtime.context().service() + && metadata.instance() == runtime.context().instance() + && metadata.state_schema_version().get() == MYC_STATE_SCHEMA_VERSION; + matches + .then_some(()) + .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence)) +} + +fn require_identity( + runtime: &MycRuntimeContext, + identity: &ServiceDatabaseIdentity, +) -> Result<(), MycStateHostError> { + let matches = 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 catalogs() -> Result< + ( + radroots_service_sqlite::MigrationCatalog, + radroots_service_sqlite::SchemaCatalog, + ), + MycStateHostError, +> { + let migrations = myc_migration_catalog() + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?; + let schema = + myc_schema_catalog().map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?; + validate_myc_state_catalogs(&migrations, &schema) + .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?; + Ok((migrations, schema)) +} + +#[derive(Debug)] +struct EmptyCatalogInitializationError; + +impl fmt::Display for EmptyCatalogInitializationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Myc baseline database reservation could not be opened") + } +} + +impl Error for EmptyCatalogInitializationError {} + +async fn initialize_empty_catalog(path: PathBuf) -> Result<(), EmptyCatalogInitializationError> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .disable_statement_logging(); + let connection = SqliteConnection::connect_with(&options) + .await + .map_err(|_| EmptyCatalogInitializationError)?; + connection + .close() + .await + .map_err(|_| EmptyCatalogInitializationError) +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -41,6 +41,13 @@ fn shared_service_sqlite_is_exactly_pinned_to_the_source_locked_lib() { } #[test] +fn shared_storage_test_evidence_is_exactly_pinned_to_the_source_locked_lib() { + assert!(MANIFEST.contains( + "radroots_storage = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\", default-features = false }" + )); +} + +#[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_host.rs b/tests/services_hardening_state_host.rs @@ -0,0 +1,187 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{error::Error, fs, num::NonZeroU32, os::unix::fs::PermissionsExt, path::Path}; + +use myc::{ + MYC_STATE_SCHEMA_VERSION, MycStateHostErrorKind, MycStateHostMode, RadrootsHostEnvironment, + RadrootsPathResolver, RadrootsPlatform, initialize_myc_state, open_myc_state_inspection, + open_myc_state_read_write, parse_myc_cli_v1_from, resolve_myc_runtime_context, +}; +use radroots_service_sqlite::{ + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceDatabaseMetadata, + ServiceSqliteApplicationId, ServiceSqlitePaths, +}; +use radroots_storage::event::SourceGeneration; + +const HOST_SOURCE: &str = include_str!("../src/state_host.rs"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); + +fn runtime(root: &Path, instance: &str) -> myc::MycRuntimeContext { + let root = root.to_str().expect("UTF-8 temporary root"); + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "repo-local", + "--instance", + instance, + "--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) -> ServiceDatabaseMetadata { + let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("SQLite paths"); + ServiceDatabaseMetadata::new( + &paths, + SourceGeneration::new([0x5a; 32]).expect("generation"), + NonZeroU32::new(MYC_STATE_SCHEMA_VERSION).expect("schema version"), + 1_725_000_000_000, + ServiceSqliteApplicationId::new(0x4d59_4331).expect("test application ID"), + ) + .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, + 1, + 1, + 1, + 1, + ) + .expect("build identity"); + (applied_at, build) +} + +#[tokio::test] +async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "primary"); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime); + let identity = metadata.identity(); + let state = runtime.artifacts().state_database(); + let lock = runtime.artifacts().state_lock(); + + assert!(!state.exists()); + initialize_myc_state(&runtime, &metadata) + .await + .expect("create-new initialization"); + assert!(state.is_file()); + assert!(lock.is_file()); + assert_eq!( + fs::metadata(state).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(lock).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + let duplicate = initialize_myc_state(&runtime, &metadata) + .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, &identity, applied_at, &build) + .await + .expect("existing writable state"); + assert_eq!(writer.mode(), MycStateHostMode::ReadWriteExisting); + assert_eq!( + format!("{writer:?}"), + "MycStateHost { mode: ReadWriteExisting, state: \"[sealed]\" }" + ); + + let contended = open_myc_state_inspection(&runtime, &identity) + .await + .expect_err("inspection must not bypass active writer authority"); + assert_eq!(contended.kind(), MycStateHostErrorKind::InspectionOpen); + writer.close().await.expect("writer close"); + writer.close().await.expect("idempotent writer close"); + + let inspection = open_myc_state_inspection(&runtime, &identity) + .await + .expect("existing inspection state"); + assert_eq!(inspection.mode(), MycStateHostMode::ReadOnlyInspection); + inspection.close().await.expect("inspection close"); + + let writer = open_myc_state_read_write(&runtime, &identity, applied_at, &build) + .await + .expect("authority reacquisition after explicit close"); + writer.close().await.expect("reopened writer close"); +} + +#[tokio::test] +async fn missing_state_and_mismatched_evidence_fail_before_database_creation() { + let directory = tempfile::tempdir().expect("temporary root"); + let primary = runtime(directory.path(), "primary"); + let secondary = runtime(directory.path(), "secondary"); + prepare_state_directory(&primary); + let primary_metadata = metadata(&primary); + let primary_identity = primary_metadata.identity(); + let (applied_at, build) = migration_evidence(); + + let missing = open_myc_state_read_write(&primary, &primary_identity, applied_at, &build) + .await + .expect_err("missing state is never created by open"); + assert_eq!(missing.kind(), MycStateHostErrorKind::ReadWriteOpen); + assert!(!primary.artifacts().state_database().exists()); + + let mismatch = initialize_myc_state(&secondary, &primary_metadata) + .await + .expect_err("cross-instance metadata"); + assert_eq!(mismatch.kind(), MycStateHostErrorKind::InvalidEvidence); + assert_eq!(mismatch.code(), "state_evidence_invalid"); + assert!(Error::source(&mismatch).is_none()); + let rendered = format!("{mismatch} {mismatch:?}"); + assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); + assert!(!rendered.contains("state.sqlite")); + assert!(!secondary.artifacts().state_database().exists()); +} + +#[test] +fn public_lifecycle_source_is_sealed() { + assert!(LIB_SOURCE.contains("mod state_host;")); + assert!(!LIB_SOURCE.contains("pub mod state_host;")); + assert!(HOST_SOURCE.contains("host: ServiceSqliteHost")); + assert!(!HOST_SOURCE.contains("pub host:")); + for forbidden in [ + "pub fn transaction", + "pub async fn transaction", + "pub fn pool", + "pub fn connection", + "pub fn into_inner", + "pub fn executor", + "MigrationDescriptor::", + "raw_sql", + "CREATE TABLE", + "PRAGMA application_id", + ] { + assert!( + !HOST_SOURCE.contains(forbidden), + "found forbidden lifecycle authority `{forbidden}`" + ); + } +}