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:
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;