commit bf5b8ccecfc7f2565394de75fa64306441fa766e
parent ae79e76e77b6204f5e6f44d395b226a683e0ef0d
Author: triesap <tyson@radroots.org>
Date: Thu, 26 Mar 2026 15:20:53 +0000
nostr-signer: implement sqlite signer store
Diffstat:
4 files changed, 792 insertions(+), 1 deletion(-)
diff --git a/crates/nostr-signer/Cargo.toml b/crates/nostr-signer/Cargo.toml
@@ -28,6 +28,7 @@ radroots-nostr-connect = { workspace = true }
radroots-runtime = { workspace = true }
radroots-sql-core = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
@@ -36,7 +37,6 @@ uuid = { workspace = true }
[dev-dependencies]
radroots-sql-core = { workspace = true, features = ["native"] }
radroots-test-fixtures = { workspace = true }
-serde_json = { workspace = true }
tempfile = { workspace = true }
[lints.rust]
diff --git a/crates/nostr-signer/src/error.rs b/crates/nostr-signer/src/error.rs
@@ -44,6 +44,12 @@ impl From<radroots_runtime::RuntimeJsonError> for RadrootsNostrSignerError {
}
}
+impl From<serde_json::Error> for RadrootsNostrSignerError {
+ fn from(value: serde_json::Error) -> Self {
+ Self::Store(value.to_string())
+ }
+}
+
#[cfg(feature = "native")]
impl From<radroots_sql_core::SqlError> for RadrootsNostrSignerError {
fn from(value: radroots_sql_core::SqlError) -> Self {
diff --git a/crates/nostr-signer/src/lib.rs b/crates/nostr-signer/src/lib.rs
@@ -40,6 +40,8 @@ pub mod prelude {
};
#[cfg(feature = "native")]
pub use crate::sqlite::RadrootsNostrSignerSqliteDb;
+ #[cfg(feature = "native")]
+ pub use crate::store::RadrootsNostrSqliteSignerStore;
pub use crate::store::{
RadrootsNostrFileSignerStore, RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore,
};
diff --git a/crates/nostr-signer/src/store.rs b/crates/nostr-signer/src/store.rs
@@ -1,9 +1,37 @@
use crate::error::RadrootsNostrSignerError;
use crate::model::RadrootsNostrSignerStoreState;
use radroots_runtime::json::{JsonFile, JsonWriteOptions};
+#[cfg(feature = "native")]
+use serde::{Deserialize, de::DeserializeOwned};
+#[cfg(feature = "native")]
+use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
+#[cfg(feature = "native")]
+use crate::model::{
+ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerApprovalState,
+ RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerAuthState,
+ RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionRecord,
+ RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest,
+ RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerRequestAuditRecord,
+ RadrootsNostrSignerRequestDecision,
+};
+#[cfg(feature = "native")]
+use crate::sqlite::RadrootsNostrSignerSqliteDb;
+#[cfg(feature = "native")]
+use nostr::RelayUrl;
+#[cfg(feature = "native")]
+use radroots_identity::RadrootsIdentityPublic;
+#[cfg(feature = "native")]
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectRequestMessage,
+};
+#[cfg(feature = "native")]
+use radroots_sql_core::SqlExecutor;
+#[cfg(feature = "native")]
+use std::collections::BTreeMap;
+
pub trait RadrootsNostrSignerStore: Send + Sync {
fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError>;
fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError>;
@@ -19,6 +47,12 @@ pub struct RadrootsNostrMemorySignerStore {
state: Arc<RwLock<RadrootsNostrSignerStoreState>>,
}
+#[cfg(feature = "native")]
+#[derive(Clone)]
+pub struct RadrootsNostrSqliteSignerStore {
+ db: Arc<RadrootsNostrSignerSqliteDb>,
+}
+
impl RadrootsNostrFileSignerStore {
pub fn new(path: impl AsRef<Path>) -> Self {
Self {
@@ -37,6 +71,21 @@ impl RadrootsNostrMemorySignerStore {
}
}
+#[cfg(feature = "native")]
+impl RadrootsNostrSqliteSignerStore {
+ pub fn open(path: impl AsRef<Path>) -> Result<Self, RadrootsNostrSignerError> {
+ Ok(Self {
+ db: Arc::new(RadrootsNostrSignerSqliteDb::open(path)?),
+ })
+ }
+
+ pub fn open_memory() -> Result<Self, RadrootsNostrSignerError> {
+ Ok(Self {
+ db: Arc::new(RadrootsNostrSignerSqliteDb::open_memory()?),
+ })
+ }
+}
+
impl RadrootsNostrSignerStore for RadrootsNostrFileSignerStore {
fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
if !self.path.exists() {
@@ -77,9 +126,632 @@ impl RadrootsNostrSignerStore for RadrootsNostrMemorySignerStore {
}
}
+#[cfg(feature = "native")]
+impl RadrootsNostrSignerStore for RadrootsNostrSqliteSignerStore {
+ fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
+ let metadata_rows: Vec<SignerStoreMetadataRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT store_version, signer_identity_json FROM signer_store_metadata WHERE singleton_id = 1",
+ )?;
+ let metadata = match metadata_rows.as_slice() {
+ [row] => row,
+ [] => {
+ return Err(RadrootsNostrSignerError::Store(
+ "sqlite signer metadata row missing".into(),
+ ));
+ }
+ _ => {
+ return Err(RadrootsNostrSignerError::Store(
+ "sqlite signer metadata row is not singular".into(),
+ ));
+ }
+ };
+
+ let mut state = RadrootsNostrSignerStoreState {
+ version: u32::try_from(metadata.store_version).map_err(|_| {
+ RadrootsNostrSignerError::Store(format!(
+ "sqlite signer store version {} is out of range",
+ metadata.store_version
+ ))
+ })?,
+ signer_identity: metadata
+ .signer_identity_json
+ .as_deref()
+ .map(parse_json_field::<RadrootsIdentityPublic>)
+ .transpose()?,
+ connections: Vec::new(),
+ audit_records: Vec::new(),
+ };
+
+ let connection_rows: Vec<SignerConnectionRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, client_public_key_hex, signer_identity_json, user_identity_json, connect_secret_hash_algorithm, connect_secret_hash_digest_hex, connect_secret_consumed_at_unix, requested_permissions_json, approval_requirement, approval_state, auth_state, status, status_reason, created_at_unix, updated_at_unix, last_authenticated_at_unix, last_request_at_unix FROM signer_connection ORDER BY created_at_unix, connection_id",
+ )?;
+ let mut connection_indexes = BTreeMap::new();
+ for row in connection_rows {
+ let connection = row.into_record()?;
+ connection_indexes.insert(
+ connection.connection_id.as_str().to_owned(),
+ state.connections.len(),
+ );
+ state.connections.push(connection);
+ }
+
+ let permission_rows: Vec<SignerConnectionPermissionGrantRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, permission, granted_at_unix FROM signer_connection_permission_grant ORDER BY connection_id, granted_at_unix, permission",
+ )?;
+ for row in permission_rows {
+ let index = *connection_indexes
+ .get(row.connection_id.as_str())
+ .ok_or_else(|| {
+ RadrootsNostrSignerError::Store(format!(
+ "permission grant row references missing connection `{}`",
+ row.connection_id
+ ))
+ })?;
+ state.connections[index]
+ .granted_permissions
+ .push(row.into_grant()?);
+ }
+
+ let relay_rows: Vec<SignerConnectionRelayRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, ordinal, relay_url FROM signer_connection_relay ORDER BY connection_id, ordinal",
+ )?;
+ for row in relay_rows {
+ let index = *connection_indexes
+ .get(row.connection_id.as_str())
+ .ok_or_else(|| {
+ RadrootsNostrSignerError::Store(format!(
+ "relay row references missing connection `{}`",
+ row.connection_id
+ ))
+ })?;
+ state.connections[index].relays.push(
+ RelayUrl::parse(row.relay_url.as_str())
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?,
+ );
+ }
+
+ let auth_rows: Vec<SignerConnectionAuthChallengeRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, auth_url, required_at_unix, authorized_at_unix FROM signer_connection_auth_challenge",
+ )?;
+ for row in auth_rows {
+ let index = *connection_indexes
+ .get(row.connection_id.as_str())
+ .ok_or_else(|| {
+ RadrootsNostrSignerError::Store(format!(
+ "auth challenge row references missing connection `{}`",
+ row.connection_id
+ ))
+ })?;
+ state.connections[index].auth_challenge = Some(
+ RadrootsNostrSignerAuthChallenge::new(row.auth_url.as_str(), row.required_at_unix)
+ .and_then(|mut challenge| {
+ challenge.authorized_at_unix = row.authorized_at_unix;
+ Ok(challenge)
+ })?,
+ );
+ }
+
+ let pending_rows: Vec<SignerConnectionPendingRequestRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT connection_id, request_message_json, created_at_unix FROM signer_connection_pending_request",
+ )?;
+ for row in pending_rows {
+ let index = *connection_indexes
+ .get(row.connection_id.as_str())
+ .ok_or_else(|| {
+ RadrootsNostrSignerError::Store(format!(
+ "pending request row references missing connection `{}`",
+ row.connection_id
+ ))
+ })?;
+ let request_message = parse_json_field::<RadrootsNostrConnectRequestMessage>(
+ row.request_message_json.as_str(),
+ )?;
+ state.connections[index].pending_request = Some(
+ RadrootsNostrSignerPendingRequest::new(request_message, row.created_at_unix)?,
+ );
+ }
+
+ let audit_rows: Vec<SignerRequestAuditRow> = query_rows(
+ self.db.as_ref(),
+ "SELECT request_id, connection_id, method, decision, message, created_at_unix FROM signer_request_audit ORDER BY created_at_unix, request_id",
+ )?;
+ state.audit_records = audit_rows
+ .into_iter()
+ .map(SignerRequestAuditRow::into_record)
+ .collect::<Result<Vec<_>, _>>()?;
+
+ Ok(state)
+ }
+
+ fn save(&self, state: &RadrootsNostrSignerStoreState) -> Result<(), RadrootsNostrSignerError> {
+ let executor = self.db.executor();
+ executor.begin()?;
+ let result = (|| -> Result<(), RadrootsNostrSignerError> {
+ exec_json(executor, "DELETE FROM signer_request_audit", json!([]))?;
+ exec_json(executor, "DELETE FROM signer_connection", json!([]))?;
+
+ exec_json(
+ executor,
+ "INSERT INTO signer_store_metadata(singleton_id, store_version, signer_identity_id, signer_identity_public_key_hex, signer_identity_json, updated_at) VALUES(1, ?, ?, ?, ?, datetime('now')) ON CONFLICT(singleton_id) DO UPDATE SET store_version = excluded.store_version, signer_identity_id = excluded.signer_identity_id, signer_identity_public_key_hex = excluded.signer_identity_public_key_hex, signer_identity_json = excluded.signer_identity_json, updated_at = excluded.updated_at",
+ json!([
+ i64::from(state.version),
+ state
+ .signer_identity
+ .as_ref()
+ .map(|identity| identity.id.to_string()),
+ state
+ .signer_identity
+ .as_ref()
+ .map(|identity| identity.public_key_hex.clone()),
+ state
+ .signer_identity
+ .as_ref()
+ .map(serde_json::to_string)
+ .transpose()?,
+ ]),
+ )?;
+
+ for connection in &state.connections {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection(connection_id, client_public_key_hex, signer_identity_id, signer_identity_public_key_hex, signer_identity_json, user_identity_id, user_identity_public_key_hex, user_identity_json, connect_secret_hash_algorithm, connect_secret_hash_digest_hex, connect_secret_consumed_at_unix, requested_permissions_json, approval_requirement, approval_state, auth_state, status, status_reason, created_at_unix, updated_at_unix, last_authenticated_at_unix, last_request_at_unix) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ connection.client_public_key.to_hex(),
+ connection.signer_identity.id.to_string(),
+ connection.signer_identity.public_key_hex.clone(),
+ serde_json::to_string(&connection.signer_identity)?,
+ connection.user_identity.id.to_string(),
+ connection.user_identity.public_key_hex.clone(),
+ serde_json::to_string(&connection.user_identity)?,
+ connection
+ .connect_secret_hash
+ .as_ref()
+ .map(|hash| secret_digest_algorithm_label(hash)),
+ connection
+ .connect_secret_hash
+ .as_ref()
+ .map(|hash| hash.digest_hex.clone()),
+ connection.connect_secret_consumed_at_unix,
+ serde_json::to_string(&connection.requested_permissions)?,
+ approval_requirement_label(connection.approval_requirement),
+ approval_state_label(connection.approval_state),
+ auth_state_label(connection.auth_state),
+ connection_status_label(connection.status),
+ connection.status_reason.clone(),
+ connection.created_at_unix,
+ connection.updated_at_unix,
+ connection.last_authenticated_at_unix,
+ connection.last_request_at_unix,
+ ]),
+ )?;
+
+ for grant in &connection.granted_permissions {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection_permission_grant(connection_id, permission, granted_at_unix) VALUES(?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ grant.permission.to_string(),
+ grant.granted_at_unix,
+ ]),
+ )?;
+ }
+
+ for (ordinal, relay) in connection.relays.iter().enumerate() {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection_relay(connection_id, ordinal, relay_url) VALUES(?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ i64::try_from(ordinal).map_err(|_| {
+ RadrootsNostrSignerError::Store(format!(
+ "relay ordinal for connection `{}` is out of range",
+ connection.connection_id
+ ))
+ })?,
+ relay.as_str(),
+ ]),
+ )?;
+ }
+
+ if let Some(challenge) = connection.auth_challenge.as_ref() {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection_auth_challenge(connection_id, auth_url, required_at_unix, authorized_at_unix) VALUES(?, ?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ challenge.auth_url,
+ challenge.required_at_unix,
+ challenge.authorized_at_unix,
+ ]),
+ )?;
+ }
+
+ if let Some(pending_request) = connection.pending_request.as_ref() {
+ exec_json(
+ executor,
+ "INSERT INTO signer_connection_pending_request(connection_id, request_message_json, created_at_unix) VALUES(?, ?, ?)",
+ json!([
+ connection.connection_id.as_str(),
+ serde_json::to_string(&pending_request.request_message)?,
+ pending_request.created_at_unix,
+ ]),
+ )?;
+ }
+ }
+
+ for audit in &state.audit_records {
+ exec_json(
+ executor,
+ "INSERT INTO signer_request_audit(request_id, connection_id, method, decision, message, created_at_unix) VALUES(?, ?, ?, ?, ?, ?)",
+ json!([
+ audit.request_id.as_str(),
+ audit.connection_id.as_str(),
+ audit.method.to_string(),
+ request_decision_label(audit.decision),
+ audit.message.clone(),
+ audit.created_at_unix,
+ ]),
+ )?;
+ }
+
+ Ok(())
+ })();
+
+ match result {
+ Ok(()) => {
+ executor.commit()?;
+ Ok(())
+ }
+ Err(error) => {
+ let _ = executor.rollback();
+ Err(error)
+ }
+ }
+ }
+}
+
+#[cfg(feature = "native")]
+#[derive(Debug, Deserialize)]
+struct SignerStoreMetadataRow {
+ store_version: i64,
+ signer_identity_json: Option<String>,
+}
+
+#[cfg(feature = "native")]
+#[derive(Debug, Deserialize)]
+struct SignerConnectionRow {
+ connection_id: String,
+ client_public_key_hex: String,
+ signer_identity_json: String,
+ user_identity_json: String,
+ connect_secret_hash_algorithm: Option<String>,
+ connect_secret_hash_digest_hex: Option<String>,
+ connect_secret_consumed_at_unix: Option<u64>,
+ requested_permissions_json: String,
+ approval_requirement: String,
+ approval_state: String,
+ auth_state: String,
+ status: String,
+ status_reason: Option<String>,
+ created_at_unix: u64,
+ updated_at_unix: u64,
+ last_authenticated_at_unix: Option<u64>,
+ last_request_at_unix: Option<u64>,
+}
+
+#[cfg(feature = "native")]
+impl SignerConnectionRow {
+ fn into_record(self) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ Ok(RadrootsNostrSignerConnectionRecord {
+ connection_id: self.connection_id.parse()?,
+ client_public_key: parse_public_key_hex(self.client_public_key_hex.as_str())?,
+ signer_identity: parse_json_field(self.signer_identity_json.as_str())?,
+ user_identity: parse_json_field(self.user_identity_json.as_str())?,
+ connect_secret_hash: match (
+ self.connect_secret_hash_algorithm.as_deref(),
+ self.connect_secret_hash_digest_hex,
+ ) {
+ (None, None) => None,
+ (Some(algorithm), Some(digest_hex)) => Some(RadrootsNostrSignerConnectSecretHash {
+ algorithm: parse_secret_digest_algorithm(algorithm)?,
+ digest_hex,
+ }),
+ _ => {
+ return Err(RadrootsNostrSignerError::Store(
+ "sqlite connection secret hash columns are inconsistent".into(),
+ ));
+ }
+ },
+ connect_secret_consumed_at_unix: self.connect_secret_consumed_at_unix,
+ requested_permissions: parse_json_field(self.requested_permissions_json.as_str())?,
+ granted_permissions: Vec::new(),
+ relays: Vec::new(),
+ approval_requirement: parse_approval_requirement(self.approval_requirement.as_str())?,
+ approval_state: parse_approval_state(self.approval_state.as_str())?,
+ auth_state: parse_auth_state(self.auth_state.as_str())?,
+ auth_challenge: None,
+ pending_request: None,
+ status: parse_connection_status(self.status.as_str())?,
+ status_reason: self.status_reason,
+ created_at_unix: self.created_at_unix,
+ updated_at_unix: self.updated_at_unix,
+ last_authenticated_at_unix: self.last_authenticated_at_unix,
+ last_request_at_unix: self.last_request_at_unix,
+ })
+ }
+}
+
+#[cfg(feature = "native")]
+#[derive(Debug, Deserialize)]
+struct SignerConnectionPermissionGrantRow {
+ connection_id: String,
+ permission: String,
+ granted_at_unix: u64,
+}
+
+#[cfg(feature = "native")]
+impl SignerConnectionPermissionGrantRow {
+ fn into_grant(self) -> Result<RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerError> {
+ Ok(RadrootsNostrSignerPermissionGrant {
+ permission: self
+ .permission
+ .parse::<RadrootsNostrConnectPermission>()
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?,
+ granted_at_unix: self.granted_at_unix,
+ })
+ }
+}
+
+#[cfg(feature = "native")]
+#[derive(Debug, Deserialize)]
+struct SignerConnectionRelayRow {
+ connection_id: String,
+ #[allow(dead_code)]
+ ordinal: i64,
+ relay_url: String,
+}
+
+#[cfg(feature = "native")]
+#[derive(Debug, Deserialize)]
+struct SignerConnectionAuthChallengeRow {
+ connection_id: String,
+ auth_url: String,
+ required_at_unix: u64,
+ authorized_at_unix: Option<u64>,
+}
+
+#[cfg(feature = "native")]
+#[derive(Debug, Deserialize)]
+struct SignerConnectionPendingRequestRow {
+ connection_id: String,
+ request_message_json: String,
+ created_at_unix: u64,
+}
+
+#[cfg(feature = "native")]
+#[derive(Debug, Deserialize)]
+struct SignerRequestAuditRow {
+ request_id: String,
+ connection_id: String,
+ method: String,
+ decision: String,
+ message: Option<String>,
+ created_at_unix: u64,
+}
+
+#[cfg(feature = "native")]
+impl SignerRequestAuditRow {
+ fn into_record(
+ self,
+ ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError> {
+ Ok(RadrootsNostrSignerRequestAuditRecord {
+ request_id: self.request_id.parse()?,
+ connection_id: self.connection_id.parse()?,
+ method: self
+ .method
+ .parse::<RadrootsNostrConnectMethod>()
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))?,
+ decision: parse_request_decision(self.decision.as_str())?,
+ message: self.message,
+ created_at_unix: self.created_at_unix,
+ })
+ }
+}
+
+#[cfg(feature = "native")]
+fn query_rows<T: DeserializeOwned>(
+ db: &RadrootsNostrSignerSqliteDb,
+ sql: &str,
+) -> Result<Vec<T>, RadrootsNostrSignerError> {
+ let raw = db.executor().query_raw(sql, "[]")?;
+ serde_json::from_str(&raw).map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))
+}
+
+#[cfg(feature = "native")]
+fn exec_json(
+ executor: &impl radroots_sql_core::SqlExecutor,
+ sql: &str,
+ params: Value,
+) -> Result<(), RadrootsNostrSignerError> {
+ let _ = executor.exec(sql, params.to_string().as_str())?;
+ Ok(())
+}
+
+#[cfg(feature = "native")]
+fn parse_json_field<T: DeserializeOwned>(value: &str) -> Result<T, RadrootsNostrSignerError> {
+ serde_json::from_str(value).map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))
+}
+
+#[cfg(feature = "native")]
+fn parse_public_key_hex(value: &str) -> Result<nostr::PublicKey, RadrootsNostrSignerError> {
+ nostr::PublicKey::parse(value)
+ .or_else(|_| nostr::PublicKey::from_hex(value))
+ .map_err(|error| RadrootsNostrSignerError::Store(error.to_string()))
+}
+
+#[cfg(feature = "native")]
+fn approval_requirement_label(value: RadrootsNostrSignerApprovalRequirement) -> &'static str {
+ match value {
+ RadrootsNostrSignerApprovalRequirement::NotRequired => "not_required",
+ RadrootsNostrSignerApprovalRequirement::ExplicitUser => "explicit_user",
+ }
+}
+
+#[cfg(feature = "native")]
+fn parse_approval_requirement(
+ value: &str,
+) -> Result<RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerError> {
+ match value {
+ "not_required" => Ok(RadrootsNostrSignerApprovalRequirement::NotRequired),
+ "explicit_user" => Ok(RadrootsNostrSignerApprovalRequirement::ExplicitUser),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite approval requirement `{other}`"
+ ))),
+ }
+}
+
+#[cfg(feature = "native")]
+fn approval_state_label(value: RadrootsNostrSignerApprovalState) -> &'static str {
+ match value {
+ RadrootsNostrSignerApprovalState::NotRequired => "not_required",
+ RadrootsNostrSignerApprovalState::Pending => "pending",
+ RadrootsNostrSignerApprovalState::Approved => "approved",
+ RadrootsNostrSignerApprovalState::Rejected => "rejected",
+ }
+}
+
+#[cfg(feature = "native")]
+fn parse_approval_state(
+ value: &str,
+) -> Result<RadrootsNostrSignerApprovalState, RadrootsNostrSignerError> {
+ match value {
+ "not_required" => Ok(RadrootsNostrSignerApprovalState::NotRequired),
+ "pending" => Ok(RadrootsNostrSignerApprovalState::Pending),
+ "approved" => Ok(RadrootsNostrSignerApprovalState::Approved),
+ "rejected" => Ok(RadrootsNostrSignerApprovalState::Rejected),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite approval state `{other}`"
+ ))),
+ }
+}
+
+#[cfg(feature = "native")]
+fn auth_state_label(value: RadrootsNostrSignerAuthState) -> &'static str {
+ match value {
+ RadrootsNostrSignerAuthState::NotRequired => "not_required",
+ RadrootsNostrSignerAuthState::Pending => "pending",
+ RadrootsNostrSignerAuthState::Authorized => "authorized",
+ }
+}
+
+#[cfg(feature = "native")]
+fn parse_auth_state(value: &str) -> Result<RadrootsNostrSignerAuthState, RadrootsNostrSignerError> {
+ match value {
+ "not_required" => Ok(RadrootsNostrSignerAuthState::NotRequired),
+ "pending" => Ok(RadrootsNostrSignerAuthState::Pending),
+ "authorized" => Ok(RadrootsNostrSignerAuthState::Authorized),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite auth state `{other}`"
+ ))),
+ }
+}
+
+#[cfg(feature = "native")]
+fn connection_status_label(value: RadrootsNostrSignerConnectionStatus) -> &'static str {
+ match value {
+ RadrootsNostrSignerConnectionStatus::Pending => "pending",
+ RadrootsNostrSignerConnectionStatus::Active => "active",
+ RadrootsNostrSignerConnectionStatus::Rejected => "rejected",
+ RadrootsNostrSignerConnectionStatus::Revoked => "revoked",
+ }
+}
+
+#[cfg(feature = "native")]
+fn parse_connection_status(
+ value: &str,
+) -> Result<RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerError> {
+ match value {
+ "pending" => Ok(RadrootsNostrSignerConnectionStatus::Pending),
+ "active" => Ok(RadrootsNostrSignerConnectionStatus::Active),
+ "rejected" => Ok(RadrootsNostrSignerConnectionStatus::Rejected),
+ "revoked" => Ok(RadrootsNostrSignerConnectionStatus::Revoked),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite connection status `{other}`"
+ ))),
+ }
+}
+
+#[cfg(feature = "native")]
+fn request_decision_label(value: RadrootsNostrSignerRequestDecision) -> &'static str {
+ match value {
+ RadrootsNostrSignerRequestDecision::Allowed => "allowed",
+ RadrootsNostrSignerRequestDecision::Denied => "denied",
+ RadrootsNostrSignerRequestDecision::Challenged => "challenged",
+ }
+}
+
+#[cfg(feature = "native")]
+fn parse_request_decision(
+ value: &str,
+) -> Result<RadrootsNostrSignerRequestDecision, RadrootsNostrSignerError> {
+ match value {
+ "allowed" => Ok(RadrootsNostrSignerRequestDecision::Allowed),
+ "denied" => Ok(RadrootsNostrSignerRequestDecision::Denied),
+ "challenged" => Ok(RadrootsNostrSignerRequestDecision::Challenged),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite request decision `{other}`"
+ ))),
+ }
+}
+
+#[cfg(feature = "native")]
+fn secret_digest_algorithm_label(hash: &RadrootsNostrSignerConnectSecretHash) -> &'static str {
+ match hash.algorithm {
+ crate::model::RadrootsNostrSignerSecretDigestAlgorithm::Sha256 => "sha256",
+ }
+}
+
+#[cfg(feature = "native")]
+fn parse_secret_digest_algorithm(
+ value: &str,
+) -> Result<crate::model::RadrootsNostrSignerSecretDigestAlgorithm, RadrootsNostrSignerError> {
+ match value {
+ "sha256" => Ok(crate::model::RadrootsNostrSignerSecretDigestAlgorithm::Sha256),
+ other => Err(RadrootsNostrSignerError::Store(format!(
+ "unknown sqlite secret digest algorithm `{other}`"
+ ))),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ #[cfg(feature = "native")]
+ use crate::model::{
+ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge,
+ RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
+ RadrootsNostrSignerConnectionId, RadrootsNostrSignerPendingRequest,
+ RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerRequestAuditRecord,
+ RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestId,
+ };
+ #[cfg(feature = "native")]
+ use crate::test_support::{
+ api_primary_https, fixture_alice_identity, fixture_bob_identity, fixture_carol_public_key,
+ primary_relay, secondary_relay,
+ };
+ #[cfg(feature = "native")]
+ use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage,
+ };
use std::thread;
#[test]
@@ -176,4 +848,115 @@ mod tests {
assert!(load.to_string().contains("memory store lock poisoned"));
assert!(save.to_string().contains("memory store lock poisoned"));
}
+
+ #[cfg(feature = "native")]
+ fn sample_request_message(id: &str) -> RadrootsNostrConnectRequestMessage {
+ RadrootsNostrConnectRequestMessage::new(id, RadrootsNostrConnectRequest::Ping)
+ }
+
+ #[cfg(feature = "native")]
+ fn sample_sqlite_state() -> RadrootsNostrSignerStoreState {
+ let signer_identity = fixture_alice_identity();
+ let user_identity = fixture_bob_identity();
+ let connection_id = RadrootsNostrSignerConnectionId::parse("conn-sqlite").expect("id");
+ let mut connection = RadrootsNostrSignerConnectionRecord::new(
+ connection_id.clone(),
+ signer_identity.clone(),
+ RadrootsNostrSignerConnectionDraft::new(fixture_carol_public_key(), user_identity)
+ .with_connect_secret("sqlite-secret")
+ .with_relays(vec![primary_relay(), secondary_relay()])
+ .with_requested_permissions(
+ vec![
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:1",
+ ),
+ ]
+ .into(),
+ )
+ .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser),
+ 100,
+ );
+ connection.approval_state = crate::model::RadrootsNostrSignerApprovalState::Approved;
+ connection.auth_state = RadrootsNostrSignerAuthState::Pending;
+ connection.status = crate::model::RadrootsNostrSignerConnectionStatus::Active;
+ connection.status_reason = Some("approved by operator".to_owned());
+ connection.updated_at_unix = 140;
+ connection.last_authenticated_at_unix = Some(130);
+ connection.last_request_at_unix = Some(135);
+ connection.mark_connect_secret_consumed(125);
+ connection.granted_permissions = vec![
+ RadrootsNostrSignerPermissionGrant::new(
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping),
+ 110,
+ ),
+ RadrootsNostrSignerPermissionGrant::new(
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:1",
+ ),
+ 111,
+ ),
+ ];
+ connection.auth_challenge = Some(
+ RadrootsNostrSignerAuthChallenge::new(
+ format!("{}/challenge", api_primary_https()).as_str(),
+ 120,
+ )
+ .expect("challenge"),
+ );
+ connection.pending_request = Some(
+ RadrootsNostrSignerPendingRequest::new(sample_request_message("req-sqlite"), 121)
+ .expect("pending request"),
+ );
+
+ RadrootsNostrSignerStoreState {
+ version: 1,
+ signer_identity: Some(signer_identity),
+ connections: vec![connection.clone()],
+ audit_records: vec![RadrootsNostrSignerRequestAuditRecord::new(
+ RadrootsNostrSignerRequestId::parse("audit-1").expect("request id"),
+ connection_id,
+ RadrootsNostrConnectMethod::Ping,
+ RadrootsNostrSignerRequestDecision::Allowed,
+ Some("permitted".to_owned()),
+ 150,
+ )],
+ }
+ }
+
+ #[cfg(feature = "native")]
+ #[test]
+ fn sqlite_store_round_trip_on_memory_backend() {
+ let store = RadrootsNostrSqliteSignerStore::open_memory().expect("open memory store");
+ let state = sample_sqlite_state();
+
+ store.save(&state).expect("save sqlite state");
+ let loaded = store.load().expect("load sqlite state");
+
+ assert_eq!(
+ serde_json::to_value(&loaded).expect("serialize loaded"),
+ serde_json::to_value(&state).expect("serialize state")
+ );
+ }
+
+ #[cfg(feature = "native")]
+ #[test]
+ fn sqlite_store_persists_to_disk_and_recovers_after_reopen() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("signer.sqlite");
+ let state = sample_sqlite_state();
+
+ let store = RadrootsNostrSqliteSignerStore::open(&path).expect("open sqlite store");
+ store.save(&state).expect("save sqlite state");
+
+ let reopened = RadrootsNostrSqliteSignerStore::open(&path).expect("reopen sqlite store");
+ let loaded = reopened.load().expect("load reopened sqlite state");
+
+ assert_eq!(
+ serde_json::to_value(&loaded).expect("serialize loaded"),
+ serde_json::to_value(&state).expect("serialize state")
+ );
+ }
}