myc

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

commit 68e5e33368f4afd6aab1309dfb2b6446c0be92d9
parent 618c609b5969968326911ba34d220b03e2e3bdd4
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 22:14:38 +0000

config: separate signer and user identities

- add an explicit managed user identity path alongside the signer identity path in repo-root config
- load signer and user identities independently during bootstrap while keeping signer state bound to the signer key
- expose distinct signer and user startup snapshots so transport work does not conflate remote-signer and user roles
- validate with cargo fmt, cargo check --locked, cargo test --locked, and cargo fmt --check

Diffstat:
Msrc/app/mod.rs | 25+++++++++++++++++--------
Msrc/app/runtime.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/config.rs | 17+++++++++++++++++
3 files changed, 127 insertions(+), 24 deletions(-)

diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -40,13 +40,11 @@ mod tests { use super::MycApp; - fn write_test_identity(path: &std::path::Path) { - RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + fn write_test_identity(path: &std::path::Path, secret_key: &str) { + RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity from secret") + .save_json(path) + .expect("write identity"); } #[test] @@ -55,7 +53,15 @@ mod tests { 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"); - write_test_identity(&config.paths.signer_identity_path); + config.paths.user_identity_path = temp.path().join("user.json"); + 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(); @@ -63,8 +69,11 @@ mod tests { assert!(snapshot.state_dir.ends_with("state")); assert!(snapshot.audit_dir.ends_with("audit")); assert!(snapshot.signer_identity_path.ends_with("identity.json")); + assert!(snapshot.user_identity_path.ends_with("user.json")); assert!(snapshot.signer_state_path.ends_with("signer-state.json")); assert!(!snapshot.signer_identity_id.is_empty()); assert!(!snapshot.signer_public_key_hex.is_empty()); + assert!(!snapshot.user_identity_id.is_empty()); + assert!(!snapshot.user_public_key_hex.is_empty()); } } diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -12,6 +12,7 @@ pub struct MycRuntimePaths { pub state_dir: PathBuf, pub audit_dir: PathBuf, pub signer_identity_path: PathBuf, + pub user_identity_path: PathBuf, pub signer_state_path: PathBuf, } @@ -22,14 +23,18 @@ pub struct MycStartupSnapshot { pub state_dir: PathBuf, pub audit_dir: PathBuf, pub signer_identity_path: PathBuf, + pub user_identity_path: PathBuf, pub signer_state_path: PathBuf, pub signer_identity_id: String, pub signer_public_key_hex: String, + pub user_identity_id: String, + pub user_public_key_hex: String, } #[derive(Clone)] pub struct MycSignerContext { - identity: RadrootsIdentity, + signer_identity: RadrootsIdentity, + user_identity: RadrootsIdentity, manager: RadrootsNostrSignerManager, } @@ -64,11 +69,19 @@ impl MycRuntime { } pub fn signer_identity(&self) -> &RadrootsIdentity { - &self.signer.identity + &self.signer.signer_identity } pub fn signer_public_identity(&self) -> RadrootsIdentityPublic { - self.signer.identity.to_public() + self.signer.signer_identity.to_public() + } + + pub fn user_identity(&self) -> &RadrootsIdentity { + &self.signer.user_identity + } + + pub fn user_public_identity(&self) -> RadrootsIdentityPublic { + self.signer.user_identity.to_public() } pub fn signer_manager(&self) -> &RadrootsNostrSignerManager { @@ -76,16 +89,20 @@ impl MycRuntime { } pub fn snapshot(&self) -> MycStartupSnapshot { - let signer_public = self.signer.identity.to_public(); + let signer_public = self.signer.signer_identity.to_public(); + let user_public = self.signer.user_identity.to_public(); MycStartupSnapshot { instance_name: self.config.service.instance_name.clone(), log_filter: self.config.logging.filter.clone(), state_dir: self.paths.state_dir.clone(), audit_dir: self.paths.audit_dir.clone(), signer_identity_path: self.paths.signer_identity_path.clone(), + user_identity_path: self.paths.user_identity_path.clone(), signer_state_path: self.paths.signer_state_path.clone(), signer_identity_id: signer_public.id.into_string(), signer_public_key_hex: signer_public.public_key_hex, + user_identity_id: user_public.id.into_string(), + user_public_key_hex: user_public.public_key_hex, } } @@ -96,9 +113,12 @@ impl MycRuntime { state_dir = %snapshot.state_dir.display(), audit_dir = %snapshot.audit_dir.display(), signer_identity_path = %snapshot.signer_identity_path.display(), + user_identity_path = %snapshot.user_identity_path.display(), signer_state_path = %snapshot.signer_state_path.display(), signer_identity_id = %snapshot.signer_identity_id, signer_public_key_hex = %snapshot.signer_public_key_hex, + user_identity_id = %snapshot.user_identity_id, + user_public_key_hex = %snapshot.user_public_key_hex, "myc runtime bootstrapped" ); Ok(()) @@ -122,6 +142,7 @@ impl MycRuntimePaths { let state_dir = config.paths.state_dir.clone(); 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"), audit_dir: state_dir.join("audit"), state_dir, @@ -131,11 +152,12 @@ impl MycRuntimePaths { impl MycSignerContext { fn bootstrap(paths: &MycRuntimePaths) -> Result<Self, MycError> { - let identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?; + let signer_identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?; + let user_identity = RadrootsIdentity::load_from_path_auto(&paths.user_identity_path)?; let manager = RadrootsNostrSignerManager::new(Arc::new( RadrootsNostrFileSignerStore::new(&paths.signer_state_path), ))?; - let configured_public = identity.to_public(); + let configured_public = signer_identity.to_public(); match manager.signer_identity()? { Some(existing) if existing.id != configured_public.id => { @@ -150,7 +172,11 @@ impl MycSignerContext { None => manager.set_signer_identity(configured_public.clone())?, } - Ok(Self { identity, manager }) + Ok(Self { + signer_identity, + user_identity, + manager, + }) } } @@ -169,13 +195,11 @@ mod tests { use super::MycRuntime; - fn write_test_identity(path: &std::path::Path) { - RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity from secret") - .save_json(path) - .expect("write identity"); + fn write_test_identity(path: &std::path::Path, secret_key: &str) { + RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity from secret") + .save_json(path) + .expect("write identity"); } #[test] @@ -184,7 +208,15 @@ mod tests { 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"); - write_test_identity(&config.paths.signer_identity_path); + config.paths.user_identity_path = temp.path().join("user.json"); + 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().state_dir.is_dir()); @@ -193,6 +225,10 @@ mod tests { runtime.paths().signer_identity_path, temp.path().join("identity.json") ); + assert_eq!( + runtime.paths().user_identity_path, + temp.path().join("user.json") + ); assert!( runtime .paths() @@ -210,6 +246,10 @@ mod tests { .to_string(), runtime.snapshot().signer_identity_id ); + assert_eq!( + runtime.user_identity().public_key_hex(), + runtime.snapshot().user_public_key_hex + ); } #[test] @@ -228,7 +268,15 @@ mod tests { fn bootstrap_rejects_mismatched_persisted_signer_identity() { let temp = tempfile::tempdir().expect("tempdir"); let identity_path = temp.path().join("identity.json"); - write_test_identity(&identity_path); + 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", @@ -245,6 +293,7 @@ mod tests { 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; let err = match MycRuntime::bootstrap(config) { Ok(_) => panic!("expected identity mismatch"), @@ -252,4 +301,32 @@ mod tests { }; assert!(matches!(err, MycError::SignerIdentityMismatch { .. })); } + + #[test] + fn bootstrap_keeps_signer_and_user_identities_distinct() { + 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"); + 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_ne!( + runtime.signer_public_identity().public_key_hex, + runtime.user_public_identity().public_key_hex + ); + assert_ne!( + runtime.snapshot().signer_identity_id, + runtime.snapshot().user_identity_id + ); + } } diff --git a/src/config.rs b/src/config.rs @@ -34,6 +34,7 @@ pub struct MycLoggingConfig { pub struct MycPathsConfig { pub state_dir: PathBuf, pub signer_identity_path: PathBuf, + pub user_identity_path: PathBuf, } impl Default for MycConfig { @@ -67,6 +68,7 @@ impl Default for MycPathsConfig { Self { state_dir: PathBuf::from("var"), signer_identity_path: PathBuf::from(DEFAULT_IDENTITY_PATH), + user_identity_path: PathBuf::from(DEFAULT_IDENTITY_PATH), } } } @@ -132,6 +134,12 @@ impl MycConfig { )); } + if self.paths.user_identity_path.as_os_str().is_empty() { + return Err(MycError::InvalidConfig( + "paths.user_identity_path must not be empty".to_owned(), + )); + } + Ok(()) } @@ -159,6 +167,10 @@ mod tests { config.paths.signer_identity_path, PathBuf::from(DEFAULT_IDENTITY_PATH) ); + assert_eq!( + config.paths.user_identity_path, + PathBuf::from(DEFAULT_IDENTITY_PATH) + ); } #[test] @@ -174,6 +186,7 @@ mod tests { [paths] state_dir = "/tmp/myc" signer_identity_path = "/tmp/myc-identity.json" + user_identity_path = "/tmp/myc-user.json" "#, ) .expect("config"); @@ -185,6 +198,10 @@ mod tests { config.paths.signer_identity_path, PathBuf::from("/tmp/myc-identity.json") ); + assert_eq!( + config.paths.user_identity_path, + PathBuf::from("/tmp/myc-user.json") + ); } #[test]