lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 62d031cb681459c945f7ef71696a9862a26a0ede
parent 7e18b73ed0d3e350a1974f4886e399b58300a1e4
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 17:28:03 +0000

nostr-signer: add signer domain crate

- add transport-neutral signer models, stores, and manager
- wire the crate into workspace, contract, release, and nix metadata
- add deterministic coverage tests for signer state and error paths
- validate radroots-nostr-signer at 100/100/100/100 coverage

Diffstat:
MCargo.lock | 15+++++++++++++++
MCargo.toml | 2++
Mcontract/coverage/policy.toml | 1+
Mcontract/release/publish-set.toml | 2++
Acrates/nostr-signer/Cargo.toml | 30++++++++++++++++++++++++++++++
Acrates/nostr-signer/src/error.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-signer/src/lib.rs | 23+++++++++++++++++++++++
Acrates/nostr-signer/src/manager.rs | 1457+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-signer/src/model.rs | 562+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr-signer/src/store.rs | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnix/common.nix | 1+
11 files changed, 2328 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2273,6 +2273,21 @@ dependencies = [ ] [[package]] +name = "radroots-nostr-signer" +version = "0.1.0-alpha.1" +dependencies = [ + "nostr", + "radroots-identity", + "radroots-nostr-connect", + "radroots-runtime", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "uuid", +] + +[[package]] name = "radroots-replica-db" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/nostr", "crates/nostr-accounts", "crates/nostr-connect", + "crates/nostr-signer", "crates/nostr-ndb", "crates/nostr-runtime", "crates/runtime", @@ -47,6 +48,7 @@ radroots-identity = { path = "crates/identity", version = "0.1.0-alpha.1", defau radroots-nostr = { path = "crates/nostr", version = "0.1.0-alpha.1", default-features = false } radroots-nostr-accounts = { path = "crates/nostr-accounts", version = "0.1.0-alpha.1", default-features = false } radroots-nostr-connect = { path = "crates/nostr-connect", version = "0.1.0-alpha.1", default-features = false } +radroots-nostr-signer = { path = "crates/nostr-signer", version = "0.1.0-alpha.1", default-features = false } radroots-nostr-ndb = { path = "crates/nostr-ndb", version = "0.1.0-alpha.1", default-features = false } radroots-runtime = { path = "crates/runtime", version = "0.1.0-alpha.1", default-features = false } radroots-log = { path = "crates/log", version = "0.1.0-alpha.1", default-features = false } diff --git a/contract/coverage/policy.toml b/contract/coverage/policy.toml @@ -23,6 +23,7 @@ crates = [ "radroots-nostr", "radroots-nostr-accounts", "radroots-nostr-connect", + "radroots-nostr-signer", "radroots-nostr-ndb", "radroots-nostr-runtime", "radroots-runtime", diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml @@ -15,6 +15,7 @@ crates = [ "radroots-events-indexed", "radroots-nostr", "radroots-nostr-connect", + "radroots-nostr-signer", "radroots-replica-db-schema", "radroots-sql-wasm-bridge", "radroots-nostr-runtime", @@ -47,6 +48,7 @@ crates = [ "radroots-events-indexed", "radroots-nostr", "radroots-nostr-connect", + "radroots-nostr-signer", "radroots-replica-db-schema", "radroots-sql-wasm-bridge", "radroots-nostr-runtime", diff --git a/crates/nostr-signer/Cargo.toml b/crates/nostr-signer/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "radroots-nostr-signer" +version = "0.1.0-alpha.1" +edition.workspace = true +authors = [ + "Radroots Authors", +] +rust-version.workspace = true +license.workspace = true +description = "transport-neutral signer domain models and persistence primitives for the radroots sdk" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots-nostr-signer" +readme.workspace = true + +[dependencies] +nostr = { workspace = true } +radroots-identity = { workspace = true, default-features = false, features = ["std", "profile"] } +radroots-nostr-connect = { workspace = true } +radroots-runtime = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +tempfile = { workspace = true } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/nostr-signer/src/error.rs b/crates/nostr-signer/src/error.rs @@ -0,0 +1,56 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RadrootsNostrSignerError { + #[error("store error: {0}")] + Store(String), + + #[error("missing signer identity")] + MissingSignerIdentity, + + #[error("connection not found: {0}")] + ConnectionNotFound(String), + + #[error( + "connection already exists for client `{client_public_key}` and user `{user_identity_id}`" + )] + ConnectionAlreadyExists { + client_public_key: String, + user_identity_id: String, + }, + + #[error("connect secret already in use")] + ConnectSecretAlreadyInUse, + + #[error("invalid signer state: {0}")] + InvalidState(String), + + #[error("invalid granted permission `{0}`")] + InvalidGrantedPermission(String), + + #[error("invalid connection id `{0}`")] + InvalidConnectionId(String), + + #[error("invalid request id `{0}`")] + InvalidRequestId(String), +} + +impl From<radroots_runtime::RuntimeJsonError> for RadrootsNostrSignerError { + fn from(value: radroots_runtime::RuntimeJsonError) -> Self { + Self::Store(value.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_runtime::RuntimeJsonError; + use std::path::PathBuf; + + #[test] + fn converts_runtime_json_error() { + let source = RuntimeJsonError::NotFound(PathBuf::from("signer.json")); + let converted: RadrootsNostrSignerError = source.into(); + assert!(converted.to_string().starts_with("store error:")); + } +} diff --git a/crates/nostr-signer/src/lib.rs b/crates/nostr-signer/src/lib.rs @@ -0,0 +1,23 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] +#![forbid(unsafe_code)] + +pub mod error; +pub mod manager; +pub mod model; +pub mod store; + +pub mod prelude { + pub use crate::error::RadrootsNostrSignerError; + pub use crate::manager::RadrootsNostrSignerManager; + pub use crate::model::{ + RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement, + RadrootsNostrSignerApprovalState, RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, + RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPermissionGrant, + RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, + RadrootsNostrSignerRequestId, RadrootsNostrSignerStoreState, + }; + pub use crate::store::{ + RadrootsNostrFileSignerStore, RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore, + }; +} diff --git a/crates/nostr-signer/src/manager.rs b/crates/nostr-signer/src/manager.rs @@ -0,0 +1,1457 @@ +use crate::error::RadrootsNostrSignerError; +use crate::model::{ + RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement, + RadrootsNostrSignerApprovalState, RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, + RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPermissionGrant, + RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, + RadrootsNostrSignerRequestId, RadrootsNostrSignerStoreState, +}; +use crate::store::{RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore}; +use nostr::{PublicKey, RelayUrl}; +use radroots_identity::RadrootsIdentityPublic; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, +}; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone)] +pub struct RadrootsNostrSignerManager { + store: Arc<dyn RadrootsNostrSignerStore>, + state: Arc<RwLock<RadrootsNostrSignerStoreState>>, +} + +impl RadrootsNostrSignerManager { + pub fn new_in_memory() -> Self { + Self { + store: Arc::new(RadrootsNostrMemorySignerStore::new()), + state: Arc::new(RwLock::new(RadrootsNostrSignerStoreState::default())), + } + } + + pub fn new(store: Arc<dyn RadrootsNostrSignerStore>) -> Result<Self, RadrootsNostrSignerError> { + let state = store.load()?; + if state.version != RADROOTS_NOSTR_SIGNER_STORE_VERSION { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "unsupported signer schema version {}", + state.version + ))); + } + + Ok(Self { + store, + state: Arc::new(RwLock::new(state)), + }) + } + + pub fn signer_identity( + &self, + ) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrSignerError> { + let guard = self + .state + .read() + .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; + Ok(guard.signer_identity.clone()) + } + + pub fn set_signer_identity( + &self, + signer_identity: RadrootsIdentityPublic, + ) -> Result<(), RadrootsNostrSignerError> { + validate_public_identity(&signer_identity)?; + self.update_state(|state| { + state.signer_identity = Some(signer_identity); + Ok(()) + }) + } + + pub fn list_connections( + &self, + ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { + let guard = self + .state + .read() + .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; + Ok(guard.connections.clone()) + } + + pub fn get_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { + let guard = self + .state + .read() + .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; + Ok(guard + .connections + .iter() + .find(|record| &record.connection_id == connection_id) + .cloned()) + } + + pub fn find_connections_by_client_public_key( + &self, + client_public_key: &PublicKey, + ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { + let guard = self + .state + .read() + .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; + Ok(guard + .connections + .iter() + .filter(|record| &record.client_public_key == client_public_key) + .cloned() + .collect()) + } + + pub fn find_connection_by_connect_secret( + &self, + connect_secret: &str, + ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { + let Some(connect_secret) = normalize_optional_string(Some(connect_secret.to_owned())) + else { + return Ok(None); + }; + + let guard = self + .state + .read() + .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; + Ok(guard + .connections + .iter() + .find(|record| { + !record.is_terminal() + && record.connect_secret.as_deref() == Some(connect_secret.as_str()) + }) + .cloned()) + } + + pub fn list_audit_records( + &self, + ) -> Result<Vec<RadrootsNostrSignerRequestAuditRecord>, RadrootsNostrSignerError> { + let guard = self + .state + .read() + .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; + Ok(guard.audit_records.clone()) + } + + pub fn audit_records_for_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<Vec<RadrootsNostrSignerRequestAuditRecord>, RadrootsNostrSignerError> { + let guard = self + .state + .read() + .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; + Ok(guard + .audit_records + .iter() + .filter(|record| &record.connection_id == connection_id) + .cloned() + .collect()) + } + + pub fn register_connection( + &self, + draft: RadrootsNostrSignerConnectionDraft, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let signer_identity = state + .signer_identity + .clone() + .ok_or(RadrootsNostrSignerError::MissingSignerIdentity)?; + validate_public_identity(&signer_identity)?; + validate_public_identity(&draft.user_identity)?; + + let connect_secret = normalize_optional_string(draft.connect_secret.clone()); + if let Some(secret) = connect_secret.as_deref() { + if state.connections.iter().any(|record| { + !record.is_terminal() && record.connect_secret.as_deref() == Some(secret) + }) { + return Err(RadrootsNostrSignerError::ConnectSecretAlreadyInUse); + } + } + + if state.connections.iter().any(|record| { + !record.is_terminal() + && record.client_public_key == draft.client_public_key + && record.user_identity.id == draft.user_identity.id + }) { + return Err(RadrootsNostrSignerError::ConnectionAlreadyExists { + client_public_key: draft.client_public_key.to_hex(), + user_identity_id: draft.user_identity.id.to_string(), + }); + } + + let created_at_unix = now_unix_secs(); + let record = RadrootsNostrSignerConnectionRecord::new( + RadrootsNostrSignerConnectionId::new_v7(), + signer_identity, + RadrootsNostrSignerConnectionDraft { + client_public_key: draft.client_public_key, + user_identity: draft.user_identity, + connect_secret, + requested_permissions: normalize_permissions(draft.requested_permissions), + relays: normalize_relays(draft.relays), + approval_requirement: draft.approval_requirement, + }, + created_at_unix, + ); + state.connections.push(record.clone()); + Ok(record) + }) + } + + pub fn set_granted_permissions( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + granted_permissions: RadrootsNostrConnectPermissions, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let updated_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + if record.is_terminal() { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "cannot update granted permissions for {} connection", + status_label(record.status) + ))); + } + + let granted_permissions = normalize_permissions(granted_permissions); + validate_granted_permissions(&record.requested_permissions, &granted_permissions)?; + record.granted_permissions = granted_permissions + .as_slice() + .iter() + .cloned() + .map(|permission| { + RadrootsNostrSignerPermissionGrant::new(permission, updated_at_unix) + }) + .collect(); + record.touch_updated(updated_at_unix); + Ok(record.clone()) + }) + } + + pub fn approve_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + granted_permissions: RadrootsNostrConnectPermissions, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let updated_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + if record.approval_requirement != RadrootsNostrSignerApprovalRequirement::ExplicitUser { + return Err(RadrootsNostrSignerError::InvalidState( + "approval not required for connection".into(), + )); + } + if record.is_terminal() { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "cannot approve {} connection", + status_label(record.status) + ))); + } + + let granted_permissions = normalize_permissions(granted_permissions); + validate_granted_permissions(&record.requested_permissions, &granted_permissions)?; + record.granted_permissions = granted_permissions + .as_slice() + .iter() + .cloned() + .map(|permission| { + RadrootsNostrSignerPermissionGrant::new(permission, updated_at_unix) + }) + .collect(); + record.approval_state = RadrootsNostrSignerApprovalState::Approved; + record.status = RadrootsNostrSignerConnectionStatus::Active; + record.status_reason = None; + record.touch_updated(updated_at_unix); + Ok(record.clone()) + }) + } + + pub fn reject_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + reason: Option<String>, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let updated_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + if record.is_terminal() { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "cannot reject {} connection", + status_label(record.status) + ))); + } + + record.approval_state = RadrootsNostrSignerApprovalState::Rejected; + record.status = RadrootsNostrSignerConnectionStatus::Rejected; + record.status_reason = normalize_optional_string(reason); + record.touch_updated(updated_at_unix); + Ok(record.clone()) + }) + } + + pub fn revoke_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + reason: Option<String>, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let updated_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + if record.status == RadrootsNostrSignerConnectionStatus::Revoked { + return Err(RadrootsNostrSignerError::InvalidState( + "connection already revoked".into(), + )); + } + + record.status = RadrootsNostrSignerConnectionStatus::Revoked; + record.status_reason = normalize_optional_string(reason); + record.touch_updated(updated_at_unix); + Ok(record.clone()) + }) + } + + pub fn update_relays( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + relays: Vec<RelayUrl>, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let updated_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + if record.is_terminal() { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "cannot update relays for {} connection", + status_label(record.status) + ))); + } + + record.relays = normalize_relays(relays); + record.touch_updated(updated_at_unix); + Ok(record.clone()) + }) + } + + pub fn mark_authenticated( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let authenticated_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + record.mark_authenticated(authenticated_at_unix); + Ok(record.clone()) + }) + } + + pub fn record_request( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + request_id: impl AsRef<str>, + method: RadrootsNostrConnectMethod, + decision: RadrootsNostrSignerRequestDecision, + message: Option<String>, + ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let created_at_unix = now_unix_secs(); + let request_id = RadrootsNostrSignerRequestId::parse(request_id.as_ref())?; + let record = find_connection_mut(state, connection_id)?; + record.mark_request(created_at_unix); + + let audit = RadrootsNostrSignerRequestAuditRecord::new( + request_id, + connection_id.clone(), + method, + decision, + normalize_optional_string(message), + created_at_unix, + ); + state.audit_records.push(audit.clone()); + Ok(audit) + }) + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn update_state( + &self, + update: impl FnOnce(&mut RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError>, + ) -> Result<(), RadrootsNostrSignerError> { + self.update_state_with(|state| { + update(state)?; + Ok(()) + }) + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn update_state_with<T>( + &self, + update: impl FnOnce(&mut RadrootsNostrSignerStoreState) -> Result<T, RadrootsNostrSignerError>, + ) -> Result<T, RadrootsNostrSignerError> { + let mut guard = self + .state + .write() + .map_err(|_| RadrootsNostrSignerError::Store("signer state lock poisoned".into()))?; + let mut next = guard.clone(); + let value = match update(&mut next) { + Ok(value) => value, + Err(err) => return Err(err), + }; + if let Err(err) = self.store.save(&next) { + return Err(err); + } + *guard = next; + Ok(value) + } +} + +fn find_connection_mut<'a>( + state: &'a mut RadrootsNostrSignerStoreState, + connection_id: &RadrootsNostrSignerConnectionId, +) -> Result<&'a mut RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + state + .connections + .iter_mut() + .find(|record| &record.connection_id == connection_id) + .ok_or_else(|| RadrootsNostrSignerError::ConnectionNotFound(connection_id.to_string())) +} + +fn validate_public_identity( + identity: &RadrootsIdentityPublic, +) -> Result<(), RadrootsNostrSignerError> { + if identity.id.as_str() != identity.public_key_hex { + return Err(RadrootsNostrSignerError::InvalidState( + "public identity id does not match public key".into(), + )); + } + Ok(()) +} + +fn validate_granted_permissions( + requested_permissions: &RadrootsNostrConnectPermissions, + granted_permissions: &RadrootsNostrConnectPermissions, +) -> Result<(), RadrootsNostrSignerError> { + if requested_permissions.is_empty() { + return Ok(()); + } + + let requested = requested_permissions.as_slice(); + if let Some(permission) = granted_permissions + .as_slice() + .iter() + .find(|permission| !requested.contains(permission)) + { + return Err(RadrootsNostrSignerError::InvalidGrantedPermission( + permission.to_string(), + )); + } + Ok(()) +} + +fn normalize_permissions( + permissions: RadrootsNostrConnectPermissions, +) -> RadrootsNostrConnectPermissions { + let mut permissions = permissions.into_vec(); + permissions.sort(); + permissions.dedup(); + permissions.into() +} + +fn normalize_relays(relays: Vec<RelayUrl>) -> Vec<RelayUrl> { + let mut relays = relays; + relays.sort_by(|left, right| left.as_str().cmp(right.as_str())); + relays.dedup_by(|left, right| left.as_str() == right.as_str()); + relays +} + +fn normalize_optional_string(value: Option<String>) -> Option<String> { + value.and_then(|value| { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn status_label(status: RadrootsNostrSignerConnectionStatus) -> &'static str { + match status { + RadrootsNostrSignerConnectionStatus::Pending => "pending", + RadrootsNostrSignerConnectionStatus::Active => "active", + RadrootsNostrSignerConnectionStatus::Rejected => "rejected", + RadrootsNostrSignerConnectionStatus::Revoked => "revoked", + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::RadrootsNostrSignerStore; + use nostr::{Keys, SecretKey}; + use radroots_identity::RadrootsIdentity; + use radroots_nostr_connect::prelude::RadrootsNostrConnectPermission; + use std::sync::Arc; + use std::thread; + + fn public_identity(secret_hex: &str) -> RadrootsIdentityPublic { + RadrootsIdentity::from_secret_key_str(secret_hex) + .expect("identity") + .to_public() + } + + fn invalid_public_identity(secret_hex: &str) -> RadrootsIdentityPublic { + let mut identity = public_identity(secret_hex); + let other = + SecretKey::from_hex("00000000000000000000000000000000000000000000000000000000000000ff") + .expect("secret"); + identity.id = + radroots_identity::RadrootsIdentityId::parse(&Keys::new(other).public_key().to_hex()) + .expect("id"); + identity + } + + fn public_key(secret_hex: &str) -> PublicKey { + let secret = SecretKey::from_hex(secret_hex).expect("secret"); + Keys::new(secret).public_key() + } + + fn permission( + method: RadrootsNostrConnectMethod, + parameter: Option<&str>, + ) -> RadrootsNostrConnectPermission { + match parameter { + Some(parameter) => RadrootsNostrConnectPermission::with_parameter(method, parameter), + None => RadrootsNostrConnectPermission::new(method), + } + } + + fn relay(url: &str) -> RelayUrl { + RelayUrl::parse(url).expect("relay") + } + + fn poison_manager_state(manager: &RadrootsNostrSignerManager) { + let shared = manager.state.clone(); + let _ = thread::spawn(move || { + let _guard = shared.write().expect("write"); + panic!("poison signer state"); + }) + .join(); + } + + fn assert_same_public_identity(left: &RadrootsIdentityPublic, right: &RadrootsIdentityPublic) { + assert_eq!(left.id.as_str(), right.id.as_str()); + assert_eq!(left.public_key_hex, right.public_key_hex); + assert_eq!(left.public_key_npub, right.public_key_npub); + } + + fn assert_same_connection( + left: &RadrootsNostrSignerConnectionRecord, + right: &RadrootsNostrSignerConnectionRecord, + ) { + assert_eq!(left.connection_id, right.connection_id); + assert_eq!(left.client_public_key, right.client_public_key); + assert_same_public_identity(&left.signer_identity, &right.signer_identity); + assert_same_public_identity(&left.user_identity, &right.user_identity); + assert_eq!(left.connect_secret, right.connect_secret); + assert_eq!(left.requested_permissions, right.requested_permissions); + assert_eq!(left.granted_permissions, right.granted_permissions); + assert_eq!(left.relays, right.relays); + assert_eq!(left.approval_requirement, right.approval_requirement); + assert_eq!(left.approval_state, right.approval_state); + assert_eq!(left.status, right.status); + assert_eq!(left.status_reason, right.status_reason); + assert_eq!(left.created_at_unix, right.created_at_unix); + assert_eq!(left.updated_at_unix, right.updated_at_unix); + assert_eq!( + left.last_authenticated_at_unix, + right.last_authenticated_at_unix + ); + assert_eq!(left.last_request_at_unix, right.last_request_at_unix); + } + + struct LoadErrorStore; + + impl RadrootsNostrSignerStore for LoadErrorStore { + fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { + Err(RadrootsNostrSignerError::Store("store load failed".into())) + } + + fn save( + &self, + _state: &RadrootsNostrSignerStoreState, + ) -> Result<(), RadrootsNostrSignerError> { + Ok(()) + } + } + + struct SaveErrorStore { + state: RwLock<RadrootsNostrSignerStoreState>, + } + + impl SaveErrorStore { + fn new(state: RadrootsNostrSignerStoreState) -> Self { + Self { + state: RwLock::new(state), + } + } + } + + impl RadrootsNostrSignerStore for SaveErrorStore { + fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { + self.state + .read() + .map(|guard| guard.clone()) + .map_err(|_| RadrootsNostrSignerError::Store("save error store poisoned".into())) + } + + fn save( + &self, + _state: &RadrootsNostrSignerStoreState, + ) -> Result<(), RadrootsNostrSignerError> { + Err(RadrootsNostrSignerError::Store("store save failed".into())) + } + } + + #[test] + fn manager_new_in_memory_and_invalid_schema_paths() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + assert!( + manager + .signer_identity() + .expect("signer identity") + .is_none() + ); + + let load_error_store = Arc::new(LoadErrorStore); + load_error_store + .save(&RadrootsNostrSignerStoreState::default()) + .expect("load error store save"); + let load_result = RadrootsNostrSignerManager::new(load_error_store); + assert!(load_result.is_err()); + let err = load_result.err().expect("load error"); + assert!(err.to_string().contains("store load failed")); + + let store = Arc::new(RadrootsNostrMemorySignerStore::new()); + let mut state = RadrootsNostrSignerStoreState::default(); + state.version = 2; + store.save(&state).expect("save"); + let version_result = RadrootsNostrSignerManager::new(store); + assert!(version_result.is_err()); + let err = version_result.err().expect("invalid version"); + assert!( + err.to_string() + .contains("unsupported signer schema version") + ); + } + + #[test] + fn set_signer_identity_validates_and_persists() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + let signer_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000001"); + manager + .set_signer_identity(signer_identity.clone()) + .expect("set signer"); + + let loaded = manager + .signer_identity() + .expect("identity") + .expect("loaded"); + assert_same_public_identity(&loaded, &signer_identity); + + let err = manager + .set_signer_identity(invalid_public_identity( + "0000000000000000000000000000000000000000000000000000000000000002", + )) + .expect_err("invalid identity"); + assert!( + err.to_string() + .contains("public identity id does not match public key") + ); + } + + #[test] + fn register_connection_requires_signer_identity_and_normalizes_inputs() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + let err = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000003"), + public_identity("0000000000000000000000000000000000000000000000000000000000000004"), + )) + .expect_err("missing signer"); + assert!(err.to_string().contains("missing signer identity")); + + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000005", + )) + .expect("set signer"); + + let sign_event = permission(RadrootsNostrConnectMethod::SignEvent, Some("kind:1")); + let ping = permission(RadrootsNostrConnectMethod::Ping, None); + let record = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000006"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000007", + ), + ) + .with_connect_secret(" secret ") + .with_requested_permissions( + vec![sign_event.clone(), ping.clone(), sign_event.clone()].into(), + ) + .with_relays(vec![ + relay("wss://z.example"), + relay("wss://a.example"), + relay("wss://a.example"), + ]), + ) + .expect("register"); + + assert_eq!(record.connect_secret.as_deref(), Some("secret")); + assert_eq!(record.status, RadrootsNostrSignerConnectionStatus::Active); + assert_eq!( + record.approval_state, + RadrootsNostrSignerApprovalState::NotRequired + ); + assert_eq!(record.requested_permissions.as_slice(), &[sign_event, ping]); + assert_eq!( + record + .relays + .iter() + .map(|relay| relay.as_str().to_owned()) + .collect::<Vec<_>>(), + vec!["wss://a.example", "wss://z.example"] + ); + } + + #[test] + fn register_connection_enforces_identity_and_uniqueness_rules() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000008", + )) + .expect("set signer"); + + let user_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000009"); + let client_public_key = + public_key("0000000000000000000000000000000000000000000000000000000000000010"); + let pending = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new(client_public_key, user_identity.clone()) + .with_connect_secret("shared-secret") + .with_approval_requirement( + RadrootsNostrSignerApprovalRequirement::ExplicitUser, + ), + ) + .expect("register"); + assert_eq!(pending.status, RadrootsNostrSignerConnectionStatus::Pending); + + let duplicate_connection = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new(client_public_key, user_identity) + .with_connect_secret("other-secret"), + ) + .expect_err("duplicate connection"); + assert!( + duplicate_connection + .to_string() + .contains("connection already exists") + ); + + let duplicate_secret = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000011"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000012", + ), + ) + .with_connect_secret("shared-secret"), + ) + .expect_err("duplicate secret"); + assert!( + duplicate_secret + .to_string() + .contains("connect secret already in use") + ); + + let invalid_user = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000013"), + invalid_public_identity( + "0000000000000000000000000000000000000000000000000000000000000014", + ), + )) + .expect_err("invalid user identity"); + assert!( + invalid_user + .to_string() + .contains("public identity id does not match public key") + ); + } + + #[test] + fn manager_query_helpers_find_connections() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000015", + )) + .expect("set signer"); + + let client_public_key = + public_key("0000000000000000000000000000000000000000000000000000000000000016"); + let record = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + client_public_key, + public_identity( + "0000000000000000000000000000000000000000000000000000000000000017", + ), + ) + .with_connect_secret("lookup-secret"), + ) + .expect("register"); + + let by_id = manager + .get_connection(&record.connection_id) + .expect("get connection"); + let by_client = manager + .find_connections_by_client_public_key(&client_public_key) + .expect("find by client"); + let by_secret = manager + .find_connection_by_connect_secret(" lookup-secret ") + .expect("find by secret"); + let empty_secret = manager + .find_connection_by_connect_secret(" ") + .expect("empty secret"); + let all_connections = manager.list_connections().expect("list connections"); + + assert_same_connection(&by_id.expect("by id"), &record); + assert_eq!(by_client.len(), 1); + assert_same_connection(&by_client[0], &record); + assert_same_connection(&by_secret.expect("by secret"), &record); + assert!(empty_secret.is_none()); + assert_eq!(all_connections.len(), 1); + assert_same_connection(&all_connections[0], &record); + } + + #[test] + fn granted_permissions_and_approval_enforce_subset_rules() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000018", + )) + .expect("set signer"); + let requested = vec![ + permission(RadrootsNostrConnectMethod::SignEvent, Some("kind:1")), + permission(RadrootsNostrConnectMethod::Ping, None), + ]; + let granted = vec![requested[1].clone()]; + let invalid = vec![permission( + RadrootsNostrConnectMethod::Nip44Encrypt, + Some("kind:1"), + )]; + let pending = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000019"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000020", + ), + ) + .with_requested_permissions(requested.clone().into()) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), + ) + .expect("register"); + + let invalid_set = manager + .set_granted_permissions(&pending.connection_id, invalid.clone().into()) + .expect_err("invalid set grants"); + assert!( + invalid_set + .to_string() + .contains("invalid granted permission") + ); + + let set_grants = manager + .set_granted_permissions(&pending.connection_id, granted.clone().into()) + .expect("set grants"); + assert_eq!( + set_grants.granted_permissions().as_slice(), + granted.as_slice() + ); + assert_eq!( + set_grants.status, + RadrootsNostrSignerConnectionStatus::Pending + ); + + let approved = manager + .approve_connection(&pending.connection_id, granted.clone().into()) + .expect("approve"); + assert_eq!(approved.status, RadrootsNostrSignerConnectionStatus::Active); + assert_eq!( + approved.approval_state, + RadrootsNostrSignerApprovalState::Approved + ); + assert_eq!( + approved.granted_permissions().as_slice(), + granted.as_slice() + ); + + let reapprove = manager + .approve_connection(&pending.connection_id, granted.into()) + .expect("reapprove active"); + assert_eq!( + reapprove.status, + RadrootsNostrSignerConnectionStatus::Active + ); + + let auto = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000021"), + public_identity("0000000000000000000000000000000000000000000000000000000000000022"), + )) + .expect("register auto"); + let err = manager + .approve_connection( + &auto.connection_id, + RadrootsNostrConnectPermissions::default(), + ) + .expect_err("approval not required"); + assert!(err.to_string().contains("approval not required")); + + let terminal_pending = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000040"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000041", + ), + ) + .with_connect_secret("terminal-secret") + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), + ) + .expect("register terminal"); + manager + .reject_connection(&terminal_pending.connection_id, Some("terminal".into())) + .expect("reject terminal"); + let terminal_approve = manager + .approve_connection( + &terminal_pending.connection_id, + vec![requested[0].clone()].into(), + ) + .expect_err("approve rejected"); + assert!( + terminal_approve + .to_string() + .contains("cannot approve rejected connection") + ); + + let unrestricted = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000023"), + public_identity("0000000000000000000000000000000000000000000000000000000000000024"), + )) + .expect("register unrestricted"); + let unrestricted_grants = manager + .set_granted_permissions(&unrestricted.connection_id, invalid.into()) + .expect("unrestricted grants"); + assert_eq!(unrestricted_grants.granted_permissions.len(), 1); + } + + #[test] + fn reject_revoke_and_relay_updates_cover_terminal_paths() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000025", + )) + .expect("set signer"); + let rejected = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000026"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000027", + ), + ) + .with_connect_secret("shared-secret") + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), + ) + .expect("register reject"); + let rejected = manager + .reject_connection(&rejected.connection_id, Some("denied".into())) + .expect("reject"); + assert_eq!( + rejected.status, + RadrootsNostrSignerConnectionStatus::Rejected + ); + assert_eq!(rejected.status_reason.as_deref(), Some("denied")); + + let reject_err = manager + .reject_connection(&rejected.connection_id, None) + .expect_err("reject terminal"); + assert!( + reject_err + .to_string() + .contains("cannot reject rejected connection") + ); + + let relay_err = manager + .update_relays(&rejected.connection_id, vec![relay("wss://relay.example")]) + .expect_err("update rejected"); + assert!( + relay_err + .to_string() + .contains("cannot update relays for rejected connection") + ); + let rejected_lookup = manager + .find_connection_by_connect_secret("shared-secret") + .expect("lookup rejected secret"); + assert!(rejected_lookup.is_none()); + + let active = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000028"), + public_identity("0000000000000000000000000000000000000000000000000000000000000029"), + )) + .expect("register active"); + let active = manager + .update_relays( + &active.connection_id, + vec![ + relay("wss://b.example"), + relay("wss://a.example"), + relay("wss://a.example"), + ], + ) + .expect("update relays"); + assert_eq!( + active + .relays + .iter() + .map(|relay| relay.as_str().to_owned()) + .collect::<Vec<_>>(), + vec!["wss://a.example", "wss://b.example"] + ); + + let revoked = manager + .revoke_connection(&active.connection_id, Some("manual".into())) + .expect("revoke"); + assert_eq!(revoked.status, RadrootsNostrSignerConnectionStatus::Revoked); + assert_eq!(revoked.status_reason.as_deref(), Some("manual")); + + let revoke_again = manager + .revoke_connection(&active.connection_id, None) + .expect_err("revoke twice"); + assert!( + revoke_again + .to_string() + .contains("connection already revoked") + ); + + let grants_err = manager + .set_granted_permissions( + &active.connection_id, + vec![permission(RadrootsNostrConnectMethod::Ping, None)].into(), + ) + .expect_err("update grants revoked"); + assert!( + grants_err + .to_string() + .contains("cannot update granted permissions for revoked connection") + ); + } + + #[test] + fn authentication_and_request_audit_paths_are_recorded() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000030", + )) + .expect("set signer"); + let record = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000031"), + public_identity("0000000000000000000000000000000000000000000000000000000000000032"), + )) + .expect("register"); + + let authenticated = manager + .mark_authenticated(&record.connection_id) + .expect("auth"); + assert!(authenticated.last_authenticated_at_unix.is_some()); + + let audit = manager + .record_request( + &record.connection_id, + " request-1 ", + RadrootsNostrConnectMethod::Ping, + RadrootsNostrSignerRequestDecision::Challenged, + Some(" challenge ".into()), + ) + .expect("record request"); + assert_eq!(audit.request_id.as_str(), "request-1"); + assert_eq!(audit.message.as_deref(), Some("challenge")); + + let all_audits = manager.list_audit_records().expect("list audits"); + let connection_audits = manager + .audit_records_for_connection(&record.connection_id) + .expect("connection audits"); + let stored = manager + .get_connection(&record.connection_id) + .expect("get") + .expect("stored"); + assert_eq!(all_audits, vec![audit.clone()]); + assert_eq!(connection_audits, vec![audit]); + assert!(stored.last_request_at_unix.is_some()); + + let request_err = manager + .record_request( + &record.connection_id, + " ", + RadrootsNostrConnectMethod::Ping, + RadrootsNostrSignerRequestDecision::Denied, + None, + ) + .expect_err("invalid request id"); + assert!(request_err.to_string().contains("invalid request id")); + } + + #[test] + fn manager_reports_missing_connections_and_save_failures() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + let missing_id = RadrootsNostrSignerConnectionId::parse("missing").expect("id"); + let missing_get = manager.get_connection(&missing_id).expect("missing get"); + assert!(missing_get.is_none()); + + let mark_err = manager + .mark_authenticated(&missing_id) + .expect_err("missing auth"); + assert!(mark_err.to_string().contains("connection not found")); + + let save_error_store = + Arc::new(SaveErrorStore::new(RadrootsNostrSignerStoreState::default())); + let loaded_state = save_error_store.load().expect("load save error store"); + assert_eq!(loaded_state.version, RADROOTS_NOSTR_SIGNER_STORE_VERSION); + let manager = RadrootsNostrSignerManager::new(save_error_store).expect("manager"); + let err = manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000033", + )) + .expect_err("save error"); + assert!(err.to_string().contains("store save failed")); + } + + #[test] + fn mutation_methods_cover_remaining_error_paths() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000051", + )) + .expect("set signer"); + + let missing_id = RadrootsNostrSignerConnectionId::parse("missing-2").expect("id"); + let missing_permissions: RadrootsNostrConnectPermissions = + vec![permission(RadrootsNostrConnectMethod::Ping, None)].into(); + + let missing_grants = manager + .set_granted_permissions(&missing_id, missing_permissions.clone()) + .expect_err("missing grants"); + let missing_approve = manager + .approve_connection(&missing_id, RadrootsNostrConnectPermissions::default()) + .expect_err("missing approve"); + let missing_reject = manager + .reject_connection(&missing_id, None) + .expect_err("missing reject"); + let missing_revoke = manager + .revoke_connection(&missing_id, None) + .expect_err("missing revoke"); + let missing_relays = manager + .update_relays(&missing_id, vec![relay("wss://relay.example")]) + .expect_err("missing relays"); + let missing_request = manager + .record_request( + &missing_id, + "req-missing", + RadrootsNostrConnectMethod::Ping, + RadrootsNostrSignerRequestDecision::Denied, + None, + ) + .expect_err("missing request"); + + for err in [ + missing_grants, + missing_approve, + missing_reject, + missing_revoke, + missing_relays, + missing_request, + ] { + assert!(err.to_string().contains("connection not found")); + } + + let requested = vec![permission(RadrootsNostrConnectMethod::Ping, None)]; + let pending = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000052"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000053", + ), + ) + .with_requested_permissions(requested.into()) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), + ) + .expect("register pending"); + let invalid_approve = manager + .approve_connection( + &pending.connection_id, + vec![permission( + RadrootsNostrConnectMethod::Nip44Encrypt, + Some("kind:1"), + )] + .into(), + ) + .expect_err("invalid approve grants"); + assert!( + invalid_approve + .to_string() + .contains("invalid granted permission") + ); + + let update_state_err = manager + .update_state(|_| Err(RadrootsNostrSignerError::InvalidState("manual".into()))) + .expect_err("update_state error"); + assert!(update_state_err.to_string().contains("manual")); + } + + #[test] + fn register_connection_rejects_invalid_persisted_signer_identity() { + let store = Arc::new(RadrootsNostrMemorySignerStore::new()); + let mut state = RadrootsNostrSignerStoreState::default(); + state.signer_identity = Some(invalid_public_identity( + "0000000000000000000000000000000000000000000000000000000000000054", + )); + store.save(&state).expect("seed state"); + + let manager = RadrootsNostrSignerManager::new(store).expect("manager"); + let err = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000055"), + public_identity("0000000000000000000000000000000000000000000000000000000000000056"), + )) + .expect_err("invalid signer identity"); + assert!( + err.to_string() + .contains("public identity id does not match public key") + ); + } + + #[test] + fn manager_reports_poisoned_state_lock() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + poison_manager_state(&manager); + + let identity = manager.signer_identity().expect_err("poisoned read"); + assert!(identity.to_string().contains("signer state lock poisoned")); + } + + #[test] + fn read_helpers_report_poisoned_state_lock() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + poison_manager_state(&manager); + + let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("id"); + let client_public_key = + public_key("0000000000000000000000000000000000000000000000000000000000000047"); + + let get_err = manager + .get_connection(&connection_id) + .expect_err("poisoned get"); + let list_err = manager.list_connections().expect_err("poisoned list"); + let audit_list_err = manager + .list_audit_records() + .expect_err("poisoned audit list"); + let audit_for_connection_err = manager + .audit_records_for_connection(&connection_id) + .expect_err("poisoned audit connection"); + let find_secret_err = manager + .find_connection_by_connect_secret("secret") + .expect_err("poisoned secret lookup"); + let find_client_err = manager + .find_connections_by_client_public_key(&client_public_key) + .expect_err("poisoned client lookup"); + + for err in [ + get_err, + list_err, + audit_list_err, + audit_for_connection_err, + find_secret_err, + find_client_err, + ] { + assert!(err.to_string().contains("signer state lock poisoned")); + } + } + + #[test] + fn mutation_helpers_report_poisoned_state_lock() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + poison_manager_state(&manager); + + let signer_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000048"); + let connection_id = RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"); + let connect_draft = RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000049"), + public_identity("0000000000000000000000000000000000000000000000000000000000000050"), + ); + + let set_signer_err = manager + .set_signer_identity(signer_identity) + .expect_err("poisoned set signer"); + let register_err = manager + .register_connection(connect_draft) + .expect_err("poisoned register"); + let grants_err = manager + .set_granted_permissions( + &connection_id, + vec![permission(RadrootsNostrConnectMethod::Ping, None)].into(), + ) + .expect_err("poisoned set grants"); + let approve_err = manager + .approve_connection(&connection_id, RadrootsNostrConnectPermissions::default()) + .expect_err("poisoned approve"); + let reject_err = manager + .reject_connection(&connection_id, Some("reason".into())) + .expect_err("poisoned reject"); + let revoke_err = manager + .revoke_connection(&connection_id, Some("reason".into())) + .expect_err("poisoned revoke"); + let update_relays_err = manager + .update_relays(&connection_id, vec![relay("wss://relay.example")]) + .expect_err("poisoned relays"); + let auth_err = manager + .mark_authenticated(&connection_id) + .expect_err("poisoned auth"); + let request_err = manager + .record_request( + &connection_id, + "req-1", + RadrootsNostrConnectMethod::Ping, + RadrootsNostrSignerRequestDecision::Allowed, + None, + ) + .expect_err("poisoned request"); + + for err in [ + set_signer_err, + register_err, + grants_err, + approve_err, + reject_err, + revoke_err, + update_relays_err, + auth_err, + request_err, + ] { + assert!(err.to_string().contains("signer state lock poisoned")); + } + } + + #[test] + fn save_error_store_reports_poisoned_load_lock() { + let store = SaveErrorStore::new(RadrootsNostrSignerStoreState::default()); + let shared = Arc::new(store); + let poison = shared.clone(); + let _ = thread::spawn(move || { + let _guard = poison.state.write().expect("write"); + panic!("poison save error store"); + }) + .join(); + + let err = shared.load().expect_err("poisoned load"); + assert!(err.to_string().contains("save error store poisoned")); + } + + #[test] + fn helpers_cover_status_labels_and_terminal_secret_reuse() { + assert_eq!( + status_label(RadrootsNostrSignerConnectionStatus::Pending), + "pending" + ); + assert_eq!( + status_label(RadrootsNostrSignerConnectionStatus::Active), + "active" + ); + assert_eq!( + status_label(RadrootsNostrSignerConnectionStatus::Rejected), + "rejected" + ); + assert_eq!( + status_label(RadrootsNostrSignerConnectionStatus::Revoked), + "revoked" + ); + + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000042", + )) + .expect("set signer"); + + let initial = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000043"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000044", + ), + ) + .with_connect_secret("reusable-secret") + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), + ) + .expect("register initial"); + manager + .reject_connection(&initial.connection_id, Some("closed".into())) + .expect("reject initial"); + + let reused = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000045"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000046", + ), + ) + .with_connect_secret("reusable-secret"), + ) + .expect("register reused secret"); + + assert_eq!(reused.connect_secret.as_deref(), Some("reusable-secret")); + } +} diff --git a/crates/nostr-signer/src/model.rs b/crates/nostr-signer/src/model.rs @@ -0,0 +1,562 @@ +use crate::error::RadrootsNostrSignerError; +use nostr::{PublicKey, RelayUrl}; +use radroots_identity::RadrootsIdentityPublic; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; +use uuid::Uuid; + +pub const RADROOTS_NOSTR_SIGNER_STORE_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RadrootsNostrSignerConnectionId(String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RadrootsNostrSignerRequestId(String); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RadrootsNostrSignerApprovalRequirement { + NotRequired, + ExplicitUser, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RadrootsNostrSignerApprovalState { + NotRequired, + Pending, + Approved, + Rejected, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RadrootsNostrSignerConnectionStatus { + Pending, + Active, + Rejected, + Revoked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RadrootsNostrSignerRequestDecision { + Allowed, + Denied, + Challenged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsNostrSignerPermissionGrant { + #[serde( + serialize_with = "serialize_permission", + deserialize_with = "deserialize_permission" + )] + pub permission: RadrootsNostrConnectPermission, + pub granted_at_unix: u64, +} + +#[derive(Debug, Clone)] +pub struct RadrootsNostrSignerConnectionDraft { + pub client_public_key: PublicKey, + pub user_identity: RadrootsIdentityPublic, + pub connect_secret: Option<String>, + pub requested_permissions: RadrootsNostrConnectPermissions, + pub relays: Vec<RelayUrl>, + pub approval_requirement: RadrootsNostrSignerApprovalRequirement, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RadrootsNostrSignerConnectionRecord { + pub connection_id: RadrootsNostrSignerConnectionId, + pub client_public_key: PublicKey, + pub signer_identity: RadrootsIdentityPublic, + pub user_identity: RadrootsIdentityPublic, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_secret: Option<String>, + pub requested_permissions: RadrootsNostrConnectPermissions, + #[serde(default)] + pub granted_permissions: Vec<RadrootsNostrSignerPermissionGrant>, + #[serde(default)] + pub relays: Vec<RelayUrl>, + pub approval_requirement: RadrootsNostrSignerApprovalRequirement, + pub approval_state: RadrootsNostrSignerApprovalState, + pub status: RadrootsNostrSignerConnectionStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_reason: Option<String>, + pub created_at_unix: u64, + pub updated_at_unix: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_authenticated_at_unix: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_request_at_unix: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsNostrSignerRequestAuditRecord { + pub request_id: RadrootsNostrSignerRequestId, + pub connection_id: RadrootsNostrSignerConnectionId, + pub method: RadrootsNostrConnectMethod, + pub decision: RadrootsNostrSignerRequestDecision, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option<String>, + pub created_at_unix: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RadrootsNostrSignerStoreState { + pub version: u32, + pub signer_identity: Option<RadrootsIdentityPublic>, + pub connections: Vec<RadrootsNostrSignerConnectionRecord>, + pub audit_records: Vec<RadrootsNostrSignerRequestAuditRecord>, +} + +impl RadrootsNostrSignerConnectionId { + pub fn new_v7() -> Self { + Self(Uuid::now_v7().to_string()) + } + + pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(RadrootsNostrSignerError::InvalidConnectionId( + value.to_owned(), + )); + } + Ok(Self(trimmed.to_owned())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl fmt::Display for RadrootsNostrSignerConnectionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef<str> for RadrootsNostrSignerConnectionId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl FromStr for RadrootsNostrSignerConnectionId { + type Err = RadrootsNostrSignerError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl RadrootsNostrSignerRequestId { + pub fn new_v7() -> Self { + Self(Uuid::now_v7().to_string()) + } + + pub fn parse(value: &str) -> Result<Self, RadrootsNostrSignerError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(RadrootsNostrSignerError::InvalidRequestId(value.to_owned())); + } + Ok(Self(trimmed.to_owned())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl fmt::Display for RadrootsNostrSignerRequestId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef<str> for RadrootsNostrSignerRequestId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl FromStr for RadrootsNostrSignerRequestId { + type Err = RadrootsNostrSignerError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl RadrootsNostrSignerPermissionGrant { + pub fn new(permission: RadrootsNostrConnectPermission, granted_at_unix: u64) -> Self { + Self { + permission, + granted_at_unix, + } + } +} + +impl RadrootsNostrSignerConnectionDraft { + pub fn new(client_public_key: PublicKey, user_identity: RadrootsIdentityPublic) -> Self { + Self { + client_public_key, + user_identity, + connect_secret: None, + requested_permissions: RadrootsNostrConnectPermissions::default(), + relays: Vec::new(), + approval_requirement: RadrootsNostrSignerApprovalRequirement::NotRequired, + } + } + + pub fn with_connect_secret(mut self, connect_secret: impl Into<String>) -> Self { + self.connect_secret = Some(connect_secret.into()); + self + } + + pub fn with_requested_permissions( + mut self, + requested_permissions: RadrootsNostrConnectPermissions, + ) -> Self { + self.requested_permissions = requested_permissions; + self + } + + pub fn with_relays(mut self, relays: Vec<RelayUrl>) -> Self { + self.relays = relays; + self + } + + pub fn with_approval_requirement( + mut self, + approval_requirement: RadrootsNostrSignerApprovalRequirement, + ) -> Self { + self.approval_requirement = approval_requirement; + self + } +} + +impl RadrootsNostrSignerConnectionRecord { + pub fn new( + connection_id: RadrootsNostrSignerConnectionId, + signer_identity: RadrootsIdentityPublic, + draft: RadrootsNostrSignerConnectionDraft, + created_at_unix: u64, + ) -> Self { + let (approval_state, status) = match draft.approval_requirement { + RadrootsNostrSignerApprovalRequirement::NotRequired => ( + RadrootsNostrSignerApprovalState::NotRequired, + RadrootsNostrSignerConnectionStatus::Active, + ), + RadrootsNostrSignerApprovalRequirement::ExplicitUser => ( + RadrootsNostrSignerApprovalState::Pending, + RadrootsNostrSignerConnectionStatus::Pending, + ), + }; + + Self { + connection_id, + client_public_key: draft.client_public_key, + signer_identity, + user_identity: draft.user_identity, + connect_secret: draft.connect_secret, + requested_permissions: draft.requested_permissions, + granted_permissions: Vec::new(), + relays: draft.relays, + approval_requirement: draft.approval_requirement, + approval_state, + status, + status_reason: None, + created_at_unix, + updated_at_unix: created_at_unix, + last_authenticated_at_unix: None, + last_request_at_unix: None, + } + } + + pub fn granted_permissions(&self) -> RadrootsNostrConnectPermissions { + self.granted_permissions + .iter() + .map(|grant| grant.permission.clone()) + .collect::<Vec<_>>() + .into() + } + + pub fn is_terminal(&self) -> bool { + matches!( + self.status, + RadrootsNostrSignerConnectionStatus::Rejected + | RadrootsNostrSignerConnectionStatus::Revoked + ) + } + + pub fn touch_updated(&mut self, updated_at_unix: u64) { + self.updated_at_unix = updated_at_unix; + } + + pub fn mark_authenticated(&mut self, authenticated_at_unix: u64) { + self.last_authenticated_at_unix = Some(authenticated_at_unix); + self.updated_at_unix = authenticated_at_unix; + } + + pub fn mark_request(&mut self, request_at_unix: u64) { + self.last_request_at_unix = Some(request_at_unix); + self.updated_at_unix = request_at_unix; + } +} + +impl RadrootsNostrSignerRequestAuditRecord { + pub fn new( + request_id: RadrootsNostrSignerRequestId, + connection_id: RadrootsNostrSignerConnectionId, + method: RadrootsNostrConnectMethod, + decision: RadrootsNostrSignerRequestDecision, + message: Option<String>, + created_at_unix: u64, + ) -> Self { + Self { + request_id, + connection_id, + method, + decision, + message, + created_at_unix, + } + } +} + +impl Default for RadrootsNostrSignerStoreState { + fn default() -> Self { + Self { + version: RADROOTS_NOSTR_SIGNER_STORE_VERSION, + signer_identity: None, + connections: Vec::new(), + audit_records: Vec::new(), + } + } +} + +fn serialize_permission<S>( + permission: &RadrootsNostrConnectPermission, + serializer: S, +) -> Result<S::Ok, S::Error> +where + S: serde::Serializer, +{ + serializer.serialize_str(&permission.to_string()) +} + +fn deserialize_permission<'de, D>( + deserializer: D, +) -> Result<RadrootsNostrConnectPermission, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{Keys, SecretKey}; + use radroots_identity::RadrootsIdentity; + use std::str::FromStr; + use tempfile::tempdir; + + fn public_identity(secret_hex: &str) -> RadrootsIdentityPublic { + RadrootsIdentity::from_secret_key_str(secret_hex) + .expect("identity") + .to_public() + } + + fn public_key(secret_hex: &str) -> PublicKey { + let secret = SecretKey::from_hex(secret_hex).expect("secret"); + Keys::new(secret).public_key() + } + + #[test] + fn connection_and_request_ids_parse_and_display() { + let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("connection"); + let request_id = RadrootsNostrSignerRequestId::parse("req-1").expect("request"); + + assert_eq!(connection_id.as_str(), "conn-1"); + assert_eq!(request_id.as_str(), "req-1"); + assert_eq!(connection_id.as_ref(), "conn-1"); + assert_eq!(request_id.as_ref(), "req-1"); + assert_eq!(connection_id.to_string(), "conn-1"); + assert_eq!(request_id.to_string(), "req-1"); + assert_eq!(connection_id.clone().into_string(), "conn-1"); + assert_eq!(request_id.clone().into_string(), "req-1"); + + let parsed_connection = + RadrootsNostrSignerConnectionId::from_str("conn-1").expect("from_str connection"); + let parsed_request = + RadrootsNostrSignerRequestId::from_str("req-1").expect("from_str request"); + assert_eq!(parsed_connection, connection_id); + assert_eq!(parsed_request, request_id); + } + + #[test] + fn generated_ids_are_non_empty() { + let connection_id = RadrootsNostrSignerConnectionId::new_v7(); + let request_id = RadrootsNostrSignerRequestId::new_v7(); + + assert!(!connection_id.as_ref().is_empty()); + assert!(!request_id.as_ref().is_empty()); + } + + #[test] + fn ids_reject_empty_values() { + let connection_err = + RadrootsNostrSignerConnectionId::parse(" ").expect_err("empty connection"); + let request_err = RadrootsNostrSignerRequestId::parse("").expect_err("empty request"); + + assert!(connection_err.to_string().contains("invalid connection id")); + assert!(request_err.to_string().contains("invalid request id")); + } + + #[test] + fn connection_draft_builders_apply_values() { + let permission = RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:1", + ); + let relay = RelayUrl::parse("wss://relay.example").expect("relay"); + let draft = RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000001"), + public_identity("0000000000000000000000000000000000000000000000000000000000000002"), + ) + .with_connect_secret(" secret ") + .with_requested_permissions(vec![permission.clone()].into()) + .with_relays(vec![relay.clone()]) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser); + + assert_eq!(draft.connect_secret.as_deref(), Some(" secret ")); + assert_eq!(draft.requested_permissions.as_slice(), &[permission]); + assert_eq!(draft.relays, vec![relay]); + assert_eq!( + draft.approval_requirement, + RadrootsNostrSignerApprovalRequirement::ExplicitUser + ); + } + + #[test] + fn connection_record_defaults_follow_approval_requirement_and_tracking_helpers() { + let signer_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000003"); + let user_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000004"); + let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("id"); + let draft = RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000005"), + user_identity, + ) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser); + let mut record = + RadrootsNostrSignerConnectionRecord::new(connection_id, signer_identity, draft, 10); + + assert_eq!(record.status, RadrootsNostrSignerConnectionStatus::Pending); + assert_eq!( + record.approval_state, + RadrootsNostrSignerApprovalState::Pending + ); + assert!(!record.is_terminal()); + + record.touch_updated(12); + record.mark_authenticated(14); + record.mark_request(16); + + assert_eq!(record.updated_at_unix, 16); + assert_eq!(record.last_authenticated_at_unix, Some(14)); + assert_eq!(record.last_request_at_unix, Some(16)); + } + + #[test] + fn granted_permissions_and_request_audit_build_correctly() { + let permission = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping); + let grant = RadrootsNostrSignerPermissionGrant::new(permission.clone(), 42); + let mut record = RadrootsNostrSignerConnectionRecord::new( + RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"), + public_identity("0000000000000000000000000000000000000000000000000000000000000006"), + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000007"), + public_identity("0000000000000000000000000000000000000000000000000000000000000008"), + ), + 20, + ); + record.granted_permissions = vec![grant]; + let audit = RadrootsNostrSignerRequestAuditRecord::new( + RadrootsNostrSignerRequestId::parse("req-2").expect("request"), + RadrootsNostrSignerConnectionId::parse("conn-2").expect("id"), + RadrootsNostrConnectMethod::Ping, + RadrootsNostrSignerRequestDecision::Allowed, + Some("ok".into()), + 25, + ); + + assert_eq!(record.granted_permissions().as_slice(), &[permission]); + assert_eq!(audit.message.as_deref(), Some("ok")); + assert_eq!(audit.created_at_unix, 25); + + let json = serde_json::to_string(&record.granted_permissions[0]).expect("serialize grant"); + let decoded: RadrootsNostrSignerPermissionGrant = + serde_json::from_str(&json).expect("deserialize grant"); + assert_eq!( + decoded.permission, + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping) + ); + } + + #[test] + fn permission_serde_helpers_round_trip_through_wrapper() { + #[derive(Debug, Serialize, Deserialize)] + struct PermissionWrapper { + #[serde( + serialize_with = "serialize_permission", + deserialize_with = "deserialize_permission" + )] + permission: RadrootsNostrConnectPermission, + } + + let wrapper = PermissionWrapper { + permission: RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:1", + ), + }; + + let json = serde_json::to_vec_pretty(&wrapper).expect("serialize wrapper"); + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("permission.json"); + std::fs::write(&path, &json).expect("write permission"); + let file = std::fs::File::open(&path).expect("open permission"); + let reader = std::io::BufReader::new(file); + let decoded: PermissionWrapper = + serde_json::from_reader(reader).expect("deserialize wrapper"); + + assert_eq!(decoded.permission, wrapper.permission); + + let invalid = serde_json::from_str::<PermissionWrapper>(r#"{"permission":1}"#) + .expect_err("invalid permission type"); + assert!(invalid.to_string().contains("invalid type")); + } + + #[test] + fn store_state_default_is_empty() { + let state = RadrootsNostrSignerStoreState::default(); + assert_eq!(state.version, RADROOTS_NOSTR_SIGNER_STORE_VERSION); + assert!(state.signer_identity.is_none()); + assert!(state.connections.is_empty()); + assert!(state.audit_records.is_empty()); + } +} diff --git a/crates/nostr-signer/src/store.rs b/crates/nostr-signer/src/store.rs @@ -0,0 +1,179 @@ +use crate::error::RadrootsNostrSignerError; +use crate::model::RadrootsNostrSignerStoreState; +use radroots_runtime::json::{JsonFile, JsonWriteOptions}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +pub trait RadrootsNostrSignerStore: Send + Sync { + fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError>; + fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError>; +} + +#[derive(Debug, Clone)] +pub struct RadrootsNostrFileSignerStore { + path: PathBuf, +} + +#[derive(Debug, Clone, Default)] +pub struct RadrootsNostrMemorySignerStore { + state: Arc<RwLock<RadrootsNostrSignerStoreState>>, +} + +impl RadrootsNostrFileSignerStore { + pub fn new(path: impl AsRef<Path>) -> Self { + Self { + path: path.as_ref().to_path_buf(), + } + } + + pub fn path(&self) -> &Path { + self.path.as_path() + } +} + +impl RadrootsNostrMemorySignerStore { + pub fn new() -> Self { + Self::default() + } +} + +impl RadrootsNostrSignerStore for RadrootsNostrFileSignerStore { + fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { + if !self.path.exists() { + return Ok(RadrootsNostrSignerStoreState::default()); + } + let file = JsonFile::<RadrootsNostrSignerStoreState>::load(self.path.as_path())?; + Ok(file.value) + } + + fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> { + let mut file = JsonFile::load_or_create_with(self.path.as_path(), || state.clone())?; + file.set_options(JsonWriteOptions { + pretty: true, + mode_unix: Some(0o600), + }); + file.value = state.clone(); + file.save()?; + Ok(()) + } +} + +impl RadrootsNostrSignerStore for RadrootsNostrMemorySignerStore { + fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { + let guard = self + .state + .read() + .map_err(|_| RadrootsNostrSignerError::Store("memory store lock poisoned".into()))?; + Ok(guard.clone()) + } + + fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> { + let mut guard = self + .state + .write() + .map_err(|_| RadrootsNostrSignerError::Store("memory store lock poisoned".into()))?; + *guard = state.clone(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn file_store_round_trip_and_path_accessor() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("signer.json"); + let store = RadrootsNostrFileSignerStore::new(path.as_path()); + + assert_eq!(store.path(), path.as_path()); + store + .save(&RadrootsNostrSignerStoreState::default()) + .expect("save"); + let loaded = store.load().expect("load"); + assert_eq!( + loaded.version, + RadrootsNostrSignerStoreState::default().version + ); + assert!(loaded.connections.is_empty()); + } + + #[test] + fn file_store_load_missing_and_reports_parse_errors() { + let temp = tempfile::tempdir().expect("tempdir"); + let missing = RadrootsNostrFileSignerStore::new(temp.path().join("missing.json")); + let loaded = missing.load().expect("missing load"); + assert!(loaded.connections.is_empty()); + + let path = temp.path().join("invalid.json"); + std::fs::write(&path, "{").expect("write invalid json"); + let store = RadrootsNostrFileSignerStore::new(path.as_path()); + let err = store.load().expect_err("invalid json"); + assert!(err.to_string().starts_with("store error:")); + } + + #[test] + fn file_store_save_reports_parse_error() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("invalid-save.json"); + std::fs::write(&path, "{").expect("write invalid json"); + let store = RadrootsNostrFileSignerStore::new(path.as_path()); + let err = store + .save(&RadrootsNostrSignerStoreState::default()) + .expect_err("invalid save"); + assert!(err.to_string().starts_with("store error:")); + } + + #[cfg(unix)] + #[test] + fn file_store_save_reports_write_error() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("signer.json"); + let json = + serde_json::to_string(&RadrootsNostrSignerStoreState::default()).expect("serialize"); + std::fs::write(&path, json).expect("write json"); + let store = RadrootsNostrFileSignerStore::new(path.as_path()); + + let mut perms = std::fs::metadata(temp.path()) + .expect("dir metadata") + .permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(temp.path(), perms).expect("set perms"); + + let err = store + .save(&RadrootsNostrSignerStoreState::default()) + .expect_err("read-only save"); + assert!(err.to_string().starts_with("store error:")); + + let mut perms = std::fs::metadata(temp.path()) + .expect("dir metadata") + .permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(temp.path(), perms).expect("restore perms"); + } + + #[test] + fn memory_store_round_trip_and_poison_errors() { + let store = RadrootsNostrMemorySignerStore::new(); + let state = RadrootsNostrSignerStoreState::default(); + store.save(&state).expect("save"); + let loaded = store.load().expect("load"); + assert_eq!(loaded.version, state.version); + + let shared = store.state.clone(); + let _ = thread::spawn(move || { + let _guard = shared.write().expect("write"); + panic!("poison memory store"); + }) + .join(); + + let load = store.load().expect_err("poisoned load"); + let save = store.save(&state).expect_err("poisoned save"); + assert!(load.to_string().contains("memory store lock poisoned")); + assert!(save.to_string().contains("memory store lock poisoned")); + } +} diff --git a/nix/common.nix b/nix/common.nix @@ -108,6 +108,7 @@ let "radroots-events-codec" "radroots-events-codec-wasm" "radroots-nostr-connect" + "radroots-nostr-signer" ]; sdkContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") sdkContractCrates); craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable;