myc

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

commit 14ff72b26eeba3d89a1cf7a161188316034caee3
parent 6d2bdc5fb5172de8e369a4658399d1eaabffbab3
Author: triesap <tyson@radroots.org>
Date:   Thu, 26 Mar 2026 16:12:31 +0000

persistence: wire sqlite signer backend

Diffstat:
MCargo.lock | 1+
MCargo.toml | 2+-
Msrc/app/mod.rs | 25++++++++++++++++++++++++-
Msrc/app/runtime.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/config.rs | 28+++++++++++++++++++++++++++-
5 files changed, 138 insertions(+), 10 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1828,6 +1828,7 @@ dependencies = [ "radroots-identity", "radroots-nostr-connect", "radroots-runtime", + "radroots-sql-core", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml @@ -22,7 +22,7 @@ radroots-log = { path = "../lib/crates/log" } radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts", default-features = false, features = ["std", "memory-vault", "os-keyring"] } radroots-nostr = { path = "../lib/crates/nostr", features = ["client", "events"] } radroots-nostr-connect = { path = "../lib/crates/nostr-connect" } -radroots-nostr-signer = { path = "../lib/crates/nostr-signer" } +radroots-nostr-signer = { path = "../lib/crates/nostr-signer", features = ["native"] } radroots-sql-core = { path = "../lib/crates/sql-core", features = ["native"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -43,7 +43,7 @@ mod tests { use radroots_identity::RadrootsIdentity; - use crate::config::MycConfig; + use crate::config::{MycConfig, MycSignerStateBackend}; use super::MycApp; @@ -89,4 +89,27 @@ mod tests { assert!(!snapshot.user_public_key_hex.is_empty()); assert!(!snapshot.transport.enabled); } + + #[test] + fn app_bootstrap_uses_backend_aware_signer_state_path() { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = MycConfig::default(); + config.paths.state_dir = PathBuf::from(temp.path()).join("state"); + config.paths.signer_identity_path = temp.path().join("identity.json"); + config.paths.user_identity_path = temp.path().join("user.json"); + config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + write_test_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_test_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + + let app = MycApp::bootstrap(config).expect("bootstrap"); + let snapshot = app.snapshot(); + + assert!(snapshot.signer_state_path.ends_with("signer-state.sqlite")); + } } diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -18,7 +18,7 @@ use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot} use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_nostr_signer::prelude::{ RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerManager, RadrootsNostrSignerStore, + RadrootsNostrSignerManager, RadrootsNostrSignerStore, RadrootsNostrSqliteSignerStore, }; use serde::Serialize; @@ -293,7 +293,10 @@ impl MycRuntimePaths { Self { signer_identity_path: config.paths.signer_identity_path.clone(), user_identity_path: config.paths.user_identity_path.clone(), - signer_state_path: state_dir.join("signer-state.json"), + signer_state_path: state_dir.join(match config.persistence.signer_state_backend { + MycSignerStateBackend::JsonFile => "signer-state.json", + MycSignerStateBackend::Sqlite => "signer-state.sqlite", + }), audit_dir: state_dir.join("audit"), state_dir, } @@ -385,7 +388,7 @@ impl MycSignerContext { MycIdentityProvider::from_source("user", user_identity_source)?; let signer_identity = signer_identity_provider.load_identity()?; let user_identity = user_identity_provider.load_identity()?; - let signer_store = Self::build_signer_store(persistence, &paths.signer_state_path); + let signer_store = Self::build_signer_store(persistence, &paths.signer_state_path)?; let operation_audit_store = Self::build_operation_audit_store(persistence, &paths.audit_dir, audit_config)?; let manager = Self::load_signer_manager_from_store(signer_store.clone())?; @@ -419,9 +422,14 @@ impl MycSignerContext { fn build_signer_store( persistence: &MycPersistenceConfig, path: &Path, - ) -> Arc<dyn RadrootsNostrSignerStore> { + ) -> Result<Arc<dyn RadrootsNostrSignerStore>, MycError> { match persistence.signer_state_backend { - MycSignerStateBackend::JsonFile => Arc::new(RadrootsNostrFileSignerStore::new(path)), + MycSignerStateBackend::JsonFile => { + Ok(Arc::new(RadrootsNostrFileSignerStore::new(path))) + } + MycSignerStateBackend::Sqlite => { + Ok(Arc::new(RadrootsNostrSqliteSignerStore::open(path)?)) + } } } @@ -498,11 +506,11 @@ mod tests { use radroots_identity::RadrootsIdentity; use radroots_nostr_signer::prelude::{ - RadrootsNostrFileSignerStore, RadrootsNostrSignerManager, + RadrootsNostrFileSignerStore, RadrootsNostrSignerManager, RadrootsNostrSqliteSignerStore, }; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; - use crate::config::{MycConfig, MycRuntimeAuditBackend}; + use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend}; use crate::error::MycError; use super::MycRuntime; @@ -672,6 +680,76 @@ mod tests { } #[test] + fn bootstrap_supports_sqlite_signer_state_backend() { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_path = temp.path().join("signer.json"); + config.paths.user_identity_path = temp.path().join("user.json"); + config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + write_test_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_test_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + + let runtime = MycRuntime::bootstrap(config).expect("runtime"); + + assert!( + runtime + .paths() + .signer_state_path + .ends_with("signer-state.sqlite") + ); + assert!(runtime.paths().signer_state_path.is_file()); + } + + #[test] + fn bootstrap_rejects_mismatched_persisted_sqlite_signer_identity() { + let temp = tempfile::tempdir().expect("tempdir"); + let identity_path = temp.path().join("identity.json"); + let user_path = temp.path().join("user.json"); + write_test_identity( + &identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_test_identity( + &user_path, + "3333333333333333333333333333333333333333333333333333333333333333", + ); + + let store_identity = RadrootsIdentity::from_secret_key_str( + "2222222222222222222222222222222222222222222222222222222222222222", + ) + .expect("second identity"); + let store = Arc::new( + RadrootsNostrSqliteSignerStore::open( + temp.path().join("state").join("signer-state.sqlite"), + ) + .expect("open sqlite store"), + ); + let manager = RadrootsNostrSignerManager::new(store).expect("manager"); + manager + .set_signer_identity(store_identity.to_public()) + .expect("persist signer"); + + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_path = identity_path; + config.paths.user_identity_path = user_path; + config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + + let err = match MycRuntime::bootstrap(config) { + Ok(_) => panic!("expected identity mismatch"), + Err(err) => err, + }; + assert!(matches!(err, MycError::SignerIdentityMismatch { .. })); + } + + #[test] fn bootstrap_supports_sqlite_operation_audit_backend() { let temp = tempfile::tempdir().expect("tempdir"); let mut config = MycConfig::default(); diff --git a/src/config.rs b/src/config.rs @@ -141,6 +141,7 @@ pub enum MycIdentityBackend { #[serde(rename_all = "snake_case")] pub enum MycSignerStateBackend { JsonFile, + Sqlite, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -368,6 +369,7 @@ impl MycSignerStateBackend { pub fn as_str(self) -> &'static str { match self { Self::JsonFile => "json_file", + Self::Sqlite => "sqlite", } } } @@ -1352,10 +1354,11 @@ fn parse_signer_state_backend_env( ) -> Result<MycSignerStateBackend, MycError> { match value { "json_file" => Ok(MycSignerStateBackend::JsonFile), + "sqlite" => Ok(MycSignerStateBackend::Sqlite), _ => Err(config_parse_error( path, line_number, - format!("{key} must be `json_file`"), + format!("{key} must be `json_file` or `sqlite`"), )), } } @@ -2314,4 +2317,27 @@ MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite .contains("MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite") ); } + + #[test] + fn parse_signer_state_backend_supports_sqlite() { + let config = MycConfig::from_env_str( + r#" +MYC_PATHS_SIGNER_IDENTITY_PATH=/tmp/signer.json +MYC_PATHS_USER_IDENTITY_PATH=/tmp/user.json +MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite + "#, + ) + .expect("config"); + + assert_eq!( + config.persistence.signer_state_backend, + MycSignerStateBackend::Sqlite + ); + assert!( + config + .to_env_string() + .expect("render env") + .contains("MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite") + ); + } }