lib

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

commit 26aea6f8e5a5659778d6239994a88b21e5b5fabf
parent d0449cc33e3b75d4d1c9d35efa66a2f361a52fab
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 19:27:58 +0000

nostr-signer: harden signer state persistence

- hash connect secrets instead of persisting plaintext bearer values
- persist auth challenge and pending request state in connection records
- add manager flows for challenge, replay, and legacy state migration
- verify the crate with cargo tests, 100 percent coverage, and nix contract

Diffstat:
MCargo.lock | 3+++
Mcrates/nostr-signer/Cargo.toml | 3+++
Mcrates/nostr-signer/src/error.rs | 3+++
Mcrates/nostr-signer/src/lib.rs | 11+++++++----
Mcrates/nostr-signer/src/manager.rs | 310++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/nostr-signer/src/model.rs | 704++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 1008 insertions(+), 26 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2276,14 +2276,17 @@ dependencies = [ name = "radroots-nostr-signer" version = "0.1.0-alpha.1" dependencies = [ + "hex", "nostr", "radroots-identity", "radroots-nostr-connect", "radroots-runtime", "serde", "serde_json", + "sha2", "tempfile", "thiserror 1.0.69", + "url", "uuid", ] diff --git a/crates/nostr-signer/Cargo.toml b/crates/nostr-signer/Cargo.toml @@ -14,6 +14,7 @@ documentation = "https://docs.rs/radroots-nostr-signer" readme.workspace = true [dependencies] +hex = { workspace = true } nostr = { workspace = true } radroots-identity = { workspace = true, default-features = false, features = [ "std", @@ -22,7 +23,9 @@ radroots-identity = { workspace = true, default-features = false, features = [ radroots-nostr-connect = { workspace = true } radroots-runtime = { workspace = true } serde = { workspace = true, features = ["derive"] } +sha2 = { workspace = true } thiserror = { workspace = true } +url = { workspace = true } uuid = { workspace = true } [dev-dependencies] diff --git a/crates/nostr-signer/src/error.rs b/crates/nostr-signer/src/error.rs @@ -22,6 +22,9 @@ pub enum RadrootsNostrSignerError { #[error("connect secret already in use")] ConnectSecretAlreadyInUse, + #[error("invalid auth url `{0}`")] + InvalidAuthUrl(String), + #[error("invalid signer state: {0}")] InvalidState(String), diff --git a/crates/nostr-signer/src/lib.rs b/crates/nostr-signer/src/lib.rs @@ -11,11 +11,14 @@ pub mod prelude { pub use crate::manager::RadrootsNostrSignerManager; pub use crate::model::{ RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerApprovalState, RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerApprovalState, RadrootsNostrSignerAuthChallenge, + RadrootsNostrSignerAuthState, RadrootsNostrSignerAuthorizationOutcome, + RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPermissionGrant, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, - RadrootsNostrSignerRequestId, RadrootsNostrSignerStoreState, + RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest, + RadrootsNostrSignerPermissionGrant, RadrootsNostrSignerRequestAuditRecord, + RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestId, + RadrootsNostrSignerSecretDigestAlgorithm, RadrootsNostrSignerStoreState, }; pub use crate::store::{ RadrootsNostrFileSignerStore, RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore, diff --git a/crates/nostr-signer/src/manager.rs b/crates/nostr-signer/src/manager.rs @@ -1,17 +1,20 @@ use crate::error::RadrootsNostrSignerError; use crate::model::{ RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerApprovalState, RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerApprovalState, RadrootsNostrSignerAuthChallenge, + RadrootsNostrSignerAuthState, RadrootsNostrSignerAuthorizationOutcome, + RadrootsNostrSignerConnectSecretHash, RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPermissionGrant, - RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, - RadrootsNostrSignerRequestId, RadrootsNostrSignerStoreState, + RadrootsNostrSignerConnectionStatus, RadrootsNostrSignerPendingRequest, + 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, + RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequestMessage, }; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -111,7 +114,8 @@ impl RadrootsNostrSignerManager { &self, connect_secret: &str, ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> { - let Some(connect_secret) = normalize_optional_string(Some(connect_secret.to_owned())) + let Some(connect_secret_hash) = + RadrootsNostrSignerConnectSecretHash::from_secret(connect_secret) else { return Ok(None); }; @@ -125,7 +129,7 @@ impl RadrootsNostrSignerManager { .iter() .find(|record| { !record.is_terminal() - && record.connect_secret.as_deref() == Some(connect_secret.as_str()) + && record.connect_secret_hash.as_ref() == Some(&connect_secret_hash) }) .cloned()) } @@ -168,10 +172,14 @@ impl RadrootsNostrSignerManager { 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() { + let connect_secret_hash = draft + .connect_secret + .as_deref() + .and_then(RadrootsNostrSignerConnectSecretHash::from_secret); + if let Some(secret_hash) = connect_secret_hash.as_ref() { if state.connections.iter().any(|record| { - !record.is_terminal() && record.connect_secret.as_deref() == Some(secret) + !record.is_terminal() + && record.connect_secret_hash.as_ref() == Some(secret_hash) }) { return Err(RadrootsNostrSignerError::ConnectSecretAlreadyInUse); } @@ -195,7 +203,7 @@ impl RadrootsNostrSignerManager { RadrootsNostrSignerConnectionDraft { client_public_key: draft.client_public_key, user_identity: draft.user_identity, - connect_secret, + connect_secret: draft.connect_secret, requested_permissions: normalize_permissions(draft.requested_permissions), relays: normalize_relays(draft.relays), approval_requirement: draft.approval_requirement, @@ -340,6 +348,80 @@ impl RadrootsNostrSignerManager { }) } + pub fn require_auth_challenge( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + auth_url: impl AsRef<str>, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let required_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + if record.is_terminal() { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "cannot require auth for {} connection", + status_label(record.status) + ))); + } + + let challenge = + RadrootsNostrSignerAuthChallenge::new(auth_url.as_ref(), required_at_unix)?; + record.require_auth_challenge(challenge); + Ok(record.clone()) + }) + } + + pub fn set_pending_request( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let record = find_connection_mut(state, connection_id)?; + if record.is_terminal() { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "cannot set pending request for {} connection", + status_label(record.status) + ))); + } + if record.auth_state != RadrootsNostrSignerAuthState::Pending { + return Err(RadrootsNostrSignerError::InvalidState( + "auth challenge not pending for connection".into(), + )); + } + + let pending_request = + RadrootsNostrSignerPendingRequest::new(request_message, now_unix_secs())?; + record.set_pending_request(pending_request); + Ok(record.clone()) + }) + } + + pub fn authorize_auth_challenge( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let record = find_connection_mut(state, connection_id)?; + if record.is_terminal() { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "cannot authorize auth challenge for {} connection", + status_label(record.status) + ))); + } + if record.auth_state != RadrootsNostrSignerAuthState::Pending { + return Err(RadrootsNostrSignerError::InvalidState( + "auth challenge not pending for connection".into(), + )); + } + + let pending_request = record.authorize_auth_challenge(now_unix_secs()); + Ok(RadrootsNostrSignerAuthorizationOutcome::new( + record.clone(), + pending_request, + )) + }) + } + pub fn mark_authenticated( &self, connection_id: &RadrootsNostrSignerConnectionId, @@ -544,6 +626,13 @@ mod tests { RelayUrl::parse(url).expect("relay") } + fn request_message(id: &str) -> RadrootsNostrConnectRequestMessage { + RadrootsNostrConnectRequestMessage::new( + id, + radroots_nostr_connect::prelude::RadrootsNostrConnectRequest::Ping, + ) + } + fn poison_manager_state(manager: &RadrootsNostrSignerManager) { let shared = manager.state.clone(); let _ = thread::spawn(move || { @@ -567,12 +656,15 @@ mod tests { 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.connect_secret_hash, right.connect_secret_hash); 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.auth_state, right.auth_state); + assert_eq!(left.auth_challenge, right.auth_challenge); + assert_eq!(left.pending_request, right.pending_request); assert_eq!(left.status, right.status); assert_eq!(left.status_reason, right.status_reason); assert_eq!(left.created_at_unix, right.created_at_unix); @@ -724,12 +816,19 @@ mod tests { ) .expect("register"); - assert_eq!(record.connect_secret.as_deref(), Some("secret")); + assert!( + record + .connect_secret_hash + .as_ref() + .expect("connect secret hash") + .matches_secret("secret") + ); assert_eq!(record.status, RadrootsNostrSignerConnectionStatus::Active); assert_eq!( record.approval_state, RadrootsNostrSignerApprovalState::NotRequired ); + assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::NotRequired); assert_eq!(record.requested_permissions.as_slice(), &[sign_event, ping]); assert_eq!( record @@ -1082,6 +1181,33 @@ mod tests { .to_string() .contains("cannot update granted permissions for revoked connection") ); + + let require_auth_err = manager + .require_auth_challenge(&active.connection_id, "https://auth.example") + .expect_err("require auth revoked"); + assert!( + require_auth_err + .to_string() + .contains("cannot require auth for revoked connection") + ); + + let pending_request_err = manager + .set_pending_request(&active.connection_id, request_message("req-terminal")) + .expect_err("pending request revoked"); + assert!( + pending_request_err + .to_string() + .contains("cannot set pending request for revoked connection") + ); + + let authorize_auth_err = manager + .authorize_auth_challenge(&active.connection_id) + .expect_err("authorize auth revoked"); + assert!( + authorize_auth_err + .to_string() + .contains("cannot authorize auth challenge for revoked connection") + ); } #[test] @@ -1116,6 +1242,17 @@ mod tests { assert_eq!(audit.request_id.as_str(), "request-1"); assert_eq!(audit.message.as_deref(), Some("challenge")); + let blank_message_audit = manager + .record_request( + &record.connection_id, + "request-2", + RadrootsNostrConnectMethod::Ping, + RadrootsNostrSignerRequestDecision::Denied, + Some(" ".into()), + ) + .expect("record blank message"); + assert!(blank_message_audit.message.is_none()); + let all_audits = manager.list_audit_records().expect("list audits"); let connection_audits = manager .audit_records_for_connection(&record.connection_id) @@ -1124,8 +1261,8 @@ mod tests { .get_connection(&record.connection_id) .expect("get") .expect("stored"); - assert_eq!(all_audits, vec![audit.clone()]); - assert_eq!(connection_audits, vec![audit]); + assert_eq!(all_audits, vec![audit.clone(), blank_message_audit.clone()]); + assert_eq!(connection_audits, vec![audit, blank_message_audit]); assert!(stored.last_request_at_unix.is_some()); let request_err = manager @@ -1141,6 +1278,100 @@ mod tests { } #[test] + fn auth_challenge_and_pending_request_state_are_persisted_and_replayed() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000034", + )) + .expect("set signer"); + let record = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000035"), + public_identity("0000000000000000000000000000000000000000000000000000000000000036"), + )) + .expect("register"); + + let required = manager + .require_auth_challenge(&record.connection_id, " https://auth.example/flow ") + .expect("require auth"); + assert_eq!(required.auth_state, RadrootsNostrSignerAuthState::Pending); + assert_eq!( + required + .auth_challenge + .as_ref() + .expect("auth challenge") + .auth_url, + "https://auth.example/flow" + ); + assert!(required.pending_request.is_none()); + + let pending = manager + .set_pending_request(&record.connection_id, request_message(" req-auth ")) + .expect("set pending request"); + assert_eq!( + pending + .pending_request + .as_ref() + .expect("pending request") + .request_id() + .as_str(), + "req-auth" + ); + + let authorized = manager + .authorize_auth_challenge(&record.connection_id) + .expect("authorize"); + assert_eq!( + authorized.connection.auth_state, + RadrootsNostrSignerAuthState::Authorized + ); + assert!(authorized.connection.last_authenticated_at_unix.is_some()); + assert!(authorized.connection.pending_request.is_none()); + assert_eq!( + authorized + .pending_request + .as_ref() + .expect("replayed request") + .request_message() + .id, + "req-auth" + ); + assert_eq!( + authorized + .connection + .auth_challenge + .as_ref() + .expect("authorized challenge") + .authorized_at_unix, + authorized.connection.last_authenticated_at_unix + ); + + let invalid_url = manager + .require_auth_challenge(&record.connection_id, "not-a-url") + .expect_err("invalid auth url"); + assert!(invalid_url.to_string().contains("invalid auth url")); + + let no_pending_auth = manager + .set_pending_request(&record.connection_id, request_message("req-again")) + .expect_err("pending request without auth challenge"); + assert!( + no_pending_auth + .to_string() + .contains("auth challenge not pending for connection") + ); + + let no_authorize = manager + .authorize_auth_challenge(&record.connection_id) + .expect_err("authorize without pending auth challenge"); + assert!( + no_authorize + .to_string() + .contains("auth challenge not pending for connection") + ); + } + + #[test] fn manager_reports_missing_connections_and_save_failures() { let manager = RadrootsNostrSignerManager::new_in_memory(); let missing_id = RadrootsNostrSignerConnectionId::parse("missing").expect("id"); @@ -1193,6 +1424,15 @@ mod tests { let missing_relays = manager .update_relays(&missing_id, vec![relay("wss://relay.example")]) .expect_err("missing relays"); + let missing_require_auth = manager + .require_auth_challenge(&missing_id, "https://auth.example") + .expect_err("missing require auth"); + let missing_pending_request = manager + .set_pending_request(&missing_id, request_message("req-missing-2")) + .expect_err("missing pending request"); + let missing_authorize_auth = manager + .authorize_auth_challenge(&missing_id) + .expect_err("missing authorize auth"); let missing_request = manager .record_request( &missing_id, @@ -1209,6 +1449,9 @@ mod tests { missing_reject, missing_revoke, missing_relays, + missing_require_auth, + missing_pending_request, + missing_authorize_auth, missing_request, ] { assert!(err.to_string().contains("connection not found")); @@ -1243,6 +1486,23 @@ mod tests { .contains("invalid granted permission") ); + let auth_required = manager + .require_auth_challenge(&pending.connection_id, "https://auth.example") + .expect("require auth"); + assert_eq!( + auth_required.auth_state, + RadrootsNostrSignerAuthState::Pending + ); + + let invalid_pending_request = manager + .set_pending_request(&pending.connection_id, request_message(" ")) + .expect_err("invalid pending request id"); + assert!( + invalid_pending_request + .to_string() + .contains("invalid request id") + ); + let update_state_err = manager .update_state(|_| Err(RadrootsNostrSignerError::InvalidState("manual".into()))) .expect_err("update_state error"); @@ -1355,6 +1615,15 @@ mod tests { let update_relays_err = manager .update_relays(&connection_id, vec![relay("wss://relay.example")]) .expect_err("poisoned relays"); + let require_auth_err = manager + .require_auth_challenge(&connection_id, "https://auth.example") + .expect_err("poisoned require auth"); + let set_pending_request_err = manager + .set_pending_request(&connection_id, request_message("req-2")) + .expect_err("poisoned set pending request"); + let authorize_auth_err = manager + .authorize_auth_challenge(&connection_id) + .expect_err("poisoned authorize auth"); let auth_err = manager .mark_authenticated(&connection_id) .expect_err("poisoned auth"); @@ -1376,6 +1645,9 @@ mod tests { reject_err, revoke_err, update_relays_err, + require_auth_err, + set_pending_request_err, + authorize_auth_err, auth_err, request_err, ] { @@ -1452,6 +1724,12 @@ mod tests { ) .expect("register reused secret"); - assert_eq!(reused.connect_secret.as_deref(), Some("reusable-secret")); + assert!( + reused + .connect_secret_hash + .as_ref() + .expect("connect secret hash") + .matches_secret("reusable-secret") + ); } } diff --git a/crates/nostr-signer/src/model.rs b/crates/nostr-signer/src/model.rs @@ -1,12 +1,16 @@ use crate::error::RadrootsNostrSignerError; +use hex::encode as hex_encode; use nostr::{PublicKey, RelayUrl}; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, + RadrootsNostrConnectRequestMessage, }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; use std::fmt; use std::str::FromStr; +use url::Url; use uuid::Uuid; pub const RADROOTS_NOSTR_SIGNER_STORE_VERSION: u32 = 1; @@ -46,6 +50,45 @@ pub enum RadrootsNostrSignerRequestDecision { Challenged, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RadrootsNostrSignerAuthState { + NotRequired, + Pending, + Authorized, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RadrootsNostrSignerSecretDigestAlgorithm { + Sha256, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsNostrSignerConnectSecretHash { + pub algorithm: RadrootsNostrSignerSecretDigestAlgorithm, + pub digest_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RadrootsNostrSignerAuthChallenge { + pub auth_url: String, + pub required_at_unix: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorized_at_unix: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsNostrSignerPendingRequest { + pub request_message: RadrootsNostrConnectRequestMessage, + pub created_at_unix: u64, +} + +#[derive(Debug, Clone)] +pub struct RadrootsNostrSignerAuthorizationOutcome { + pub connection: RadrootsNostrSignerConnectionRecord, + pub pending_request: Option<RadrootsNostrSignerPendingRequest>, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsNostrSignerPermissionGrant { #[serde( @@ -72,8 +115,13 @@ pub struct RadrootsNostrSignerConnectionRecord { 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>, + #[serde( + default, + alias = "connect_secret", + deserialize_with = "deserialize_connect_secret_hash_option", + skip_serializing_if = "Option::is_none" + )] + pub connect_secret_hash: Option<RadrootsNostrSignerConnectSecretHash>, pub requested_permissions: RadrootsNostrConnectPermissions, #[serde(default)] pub granted_permissions: Vec<RadrootsNostrSignerPermissionGrant>, @@ -81,6 +129,12 @@ pub struct RadrootsNostrSignerConnectionRecord { pub relays: Vec<RelayUrl>, pub approval_requirement: RadrootsNostrSignerApprovalRequirement, pub approval_state: RadrootsNostrSignerApprovalState, + #[serde(default)] + pub auth_state: RadrootsNostrSignerAuthState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_challenge: Option<RadrootsNostrSignerAuthChallenge>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_request: Option<RadrootsNostrSignerPendingRequest>, pub status: RadrootsNostrSignerConnectionStatus, #[serde(default, skip_serializing_if = "Option::is_none")] pub status_reason: Option<String>, @@ -111,6 +165,13 @@ pub struct RadrootsNostrSignerStoreState { pub audit_records: Vec<RadrootsNostrSignerRequestAuditRecord>, } +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RadrootsNostrSignerConnectSecretHashRepr { + Hash(RadrootsNostrSignerConnectSecretHash), + LegacyPlaintext(String), +} + impl RadrootsNostrSignerConnectionId { pub fn new_v7() -> Self { Self(Uuid::now_v7().to_string()) @@ -197,6 +258,117 @@ impl FromStr for RadrootsNostrSignerRequestId { } } +impl RadrootsNostrSignerConnectSecretHash { + pub fn from_secret(secret: &str) -> Option<Self> { + normalize_optional_string(secret).map(|normalized| { + let mut hasher = Sha256::new(); + hasher.update(normalized.as_bytes()); + Self { + algorithm: RadrootsNostrSignerSecretDigestAlgorithm::Sha256, + digest_hex: hex_encode(hasher.finalize()), + } + }) + } + + pub fn matches_secret(&self, secret: &str) -> bool { + Self::from_secret(secret).as_ref() == Some(self) + } + + fn normalize(self) -> Result<Self, String> { + let digest_hex = self.digest_hex.trim().to_ascii_lowercase(); + if digest_hex.len() != 64 || !digest_hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err("invalid connect secret digest".into()); + } + Ok(Self { + algorithm: self.algorithm, + digest_hex, + }) + } +} + +impl RadrootsNostrSignerAuthChallenge { + pub fn new(auth_url: &str, required_at_unix: u64) -> Result<Self, RadrootsNostrSignerError> { + let auth_url = normalize_optional_string(auth_url) + .ok_or_else(|| RadrootsNostrSignerError::InvalidAuthUrl(auth_url.to_owned()))?; + let auth_url: String = Url::parse(&auth_url) + .map_err(|_| RadrootsNostrSignerError::InvalidAuthUrl(auth_url.clone()))? + .into(); + Ok(Self { + auth_url, + required_at_unix, + authorized_at_unix: None, + }) + } + + pub fn mark_authorized(&mut self, authorized_at_unix: u64) { + self.authorized_at_unix = Some(authorized_at_unix); + } +} + +impl<'de> Deserialize<'de> for RadrootsNostrSignerAuthChallenge { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct RawAuthChallenge { + auth_url: String, + required_at_unix: u64, + #[serde(default)] + authorized_at_unix: Option<u64>, + } + + let raw = RawAuthChallenge::deserialize(deserializer)?; + let mut challenge = + Self::new(&raw.auth_url, raw.required_at_unix).map_err(serde::de::Error::custom)?; + challenge.authorized_at_unix = raw.authorized_at_unix; + Ok(challenge) + } +} + +impl RadrootsNostrSignerPendingRequest { + pub fn new( + request_message: RadrootsNostrConnectRequestMessage, + created_at_unix: u64, + ) -> Result<Self, RadrootsNostrSignerError> { + let normalized_id = RadrootsNostrSignerRequestId::parse(&request_message.id)?; + Ok(Self { + request_message: RadrootsNostrConnectRequestMessage::new( + normalized_id.as_str(), + request_message.request, + ), + created_at_unix, + }) + } + + pub fn request_message(&self) -> RadrootsNostrConnectRequestMessage { + self.request_message.clone() + } + + pub fn request_id(&self) -> RadrootsNostrSignerRequestId { + RadrootsNostrSignerRequestId::parse(&self.request_message.id) + .expect("pending request ids are validated on construction") + } +} + +impl RadrootsNostrSignerAuthorizationOutcome { + pub fn new( + connection: RadrootsNostrSignerConnectionRecord, + pending_request: Option<RadrootsNostrSignerPendingRequest>, + ) -> Self { + Self { + connection, + pending_request, + } + } +} + +impl Default for RadrootsNostrSignerAuthState { + fn default() -> Self { + Self::NotRequired + } +} + impl RadrootsNostrSignerPermissionGrant { pub fn new(permission: RadrootsNostrConnectPermission, granted_at_unix: u64) -> Self { Self { @@ -268,12 +440,18 @@ impl RadrootsNostrSignerConnectionRecord { client_public_key: draft.client_public_key, signer_identity, user_identity: draft.user_identity, - connect_secret: draft.connect_secret, + connect_secret_hash: draft + .connect_secret + .as_deref() + .and_then(RadrootsNostrSignerConnectSecretHash::from_secret), requested_permissions: draft.requested_permissions, granted_permissions: Vec::new(), relays: draft.relays, approval_requirement: draft.approval_requirement, approval_state, + auth_state: RadrootsNostrSignerAuthState::NotRequired, + auth_challenge: None, + pending_request: None, status, status_reason: None, created_at_unix, @@ -312,6 +490,31 @@ impl RadrootsNostrSignerConnectionRecord { self.last_request_at_unix = Some(request_at_unix); self.updated_at_unix = request_at_unix; } + + pub fn require_auth_challenge(&mut self, auth_challenge: RadrootsNostrSignerAuthChallenge) { + self.auth_state = RadrootsNostrSignerAuthState::Pending; + self.auth_challenge = Some(auth_challenge.clone()); + self.pending_request = None; + self.updated_at_unix = auth_challenge.required_at_unix; + } + + pub fn set_pending_request(&mut self, pending_request: RadrootsNostrSignerPendingRequest) { + self.pending_request = Some(pending_request.clone()); + self.updated_at_unix = pending_request.created_at_unix; + } + + pub fn authorize_auth_challenge( + &mut self, + authorized_at_unix: u64, + ) -> Option<RadrootsNostrSignerPendingRequest> { + self.auth_state = RadrootsNostrSignerAuthState::Authorized; + if let Some(auth_challenge) = self.auth_challenge.as_mut() { + auth_challenge.mark_authorized(authorized_at_unix); + } + self.last_authenticated_at_unix = Some(authorized_at_unix); + self.updated_at_unix = authorized_at_unix; + self.pending_request.take() + } } impl RadrootsNostrSignerRequestAuditRecord { @@ -365,11 +568,39 @@ where value.parse().map_err(serde::de::Error::custom) } +fn deserialize_connect_secret_hash_option<'de, D>( + deserializer: D, +) -> Result<Option<RadrootsNostrSignerConnectSecretHash>, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::<RadrootsNostrSignerConnectSecretHashRepr>::deserialize(deserializer)?; + match value { + None => Ok(None), + Some(RadrootsNostrSignerConnectSecretHashRepr::Hash(hash)) => { + hash.normalize().map(Some).map_err(serde::de::Error::custom) + } + Some(RadrootsNostrSignerConnectSecretHashRepr::LegacyPlaintext(secret)) => { + Ok(RadrootsNostrSignerConnectSecretHash::from_secret(&secret)) + } + } +} + +fn normalize_optional_string(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + #[cfg(test)] mod tests { use super::*; use nostr::{Keys, SecretKey}; use radroots_identity::RadrootsIdentity; + use serde_json::json; use std::str::FromStr; use tempfile::tempdir; @@ -384,6 +615,13 @@ mod tests { Keys::new(secret).public_key() } + fn request_message(id: &str) -> RadrootsNostrConnectRequestMessage { + RadrootsNostrConnectRequestMessage::new( + id, + radroots_nostr_connect::prelude::RadrootsNostrConnectRequest::Ping, + ) + } + #[test] fn connection_and_request_ids_parse_and_display() { let connection_id = RadrootsNostrSignerConnectionId::parse("conn-1").expect("connection"); @@ -461,6 +699,7 @@ mod tests { public_key("0000000000000000000000000000000000000000000000000000000000000005"), user_identity, ) + .with_connect_secret(" secret ") .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser); let mut record = RadrootsNostrSignerConnectionRecord::new(connection_id, signer_identity, draft, 10); @@ -470,15 +709,54 @@ mod tests { record.approval_state, RadrootsNostrSignerApprovalState::Pending ); + assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::NotRequired); + assert!( + record + .connect_secret_hash + .as_ref() + .expect("connect secret hash") + .matches_secret("secret") + ); assert!(!record.is_terminal()); record.touch_updated(12); record.mark_authenticated(14); record.mark_request(16); + record.require_auth_challenge( + RadrootsNostrSignerAuthChallenge::new("https://auth.example/path", 18) + .expect("auth challenge"), + ); + record.set_pending_request( + RadrootsNostrSignerPendingRequest::new(request_message("req-1"), 20) + .expect("pending request"), + ); + let replay = record.authorize_auth_challenge(22).expect("replay"); + let no_challenge_replay = RadrootsNostrSignerConnectionRecord::new( + RadrootsNostrSignerConnectionId::parse("conn-1b").expect("id"), + public_identity("0000000000000000000000000000000000000000000000000000000000000009"), + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000010"), + public_identity("0000000000000000000000000000000000000000000000000000000000000011"), + ), + 24, + ) + .authorize_auth_challenge(25); - assert_eq!(record.updated_at_unix, 16); - assert_eq!(record.last_authenticated_at_unix, Some(14)); + assert_eq!(record.updated_at_unix, 22); + assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Authorized); + assert_eq!( + record + .auth_challenge + .as_ref() + .expect("auth challenge") + .authorized_at_unix, + Some(22) + ); + assert!(record.pending_request.is_none()); + assert_eq!(record.last_authenticated_at_unix, Some(22)); assert_eq!(record.last_request_at_unix, Some(16)); + assert_eq!(replay.request_id().as_str(), "req-1"); + assert!(no_challenge_replay.is_none()); } #[test] @@ -546,9 +824,423 @@ mod tests { assert_eq!(decoded.permission, wrapper.permission); + let value = serde_json::to_value(&wrapper).expect("serialize wrapper to value"); + let decoded_from_value: PermissionWrapper = + serde_json::from_value(value).expect("deserialize wrapper from value"); + assert_eq!(decoded_from_value.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")); + + let invalid_from_value = + serde_json::from_value::<PermissionWrapper>(json!({ "permission": 1 })) + .expect_err("invalid permission type from value"); + assert!(invalid_from_value.to_string().contains("invalid type")); + + let invalid_path = temp.path().join("invalid-permission.json"); + std::fs::write(&invalid_path, br#"{"permission":1}"#).expect("write invalid permission"); + let invalid_file = std::fs::File::open(&invalid_path).expect("open invalid permission"); + let invalid_reader = std::io::BufReader::new(invalid_file); + let invalid_from_reader = serde_json::from_reader::<_, PermissionWrapper>(invalid_reader) + .expect_err("invalid permission type from reader"); + assert!(invalid_from_reader.to_string().contains("invalid type")); + } + + #[test] + fn connect_secret_hash_and_pending_request_helpers_validate_inputs() { + let hash = + RadrootsNostrSignerConnectSecretHash::from_secret(" secret ").expect("secret hash"); + assert!(hash.matches_secret("secret")); + assert!(!hash.matches_secret("other")); + assert!(RadrootsNostrSignerConnectSecretHash::from_secret(" ").is_none()); + + let pending = RadrootsNostrSignerPendingRequest::new(request_message("req-2"), 30) + .expect("pending request"); + assert_eq!(pending.request_id().as_str(), "req-2"); + assert_eq!(pending.request_message().id, "req-2"); + + let invalid_pending = RadrootsNostrSignerPendingRequest::new(request_message(" "), 30) + .expect_err("invalid pending request id"); + assert!(invalid_pending.to_string().contains("invalid request id")); + + let challenge = + RadrootsNostrSignerAuthChallenge::new(" https://auth.example ", 31).expect("challenge"); + assert_eq!(challenge.auth_url, "https://auth.example/"); + + let invalid_challenge = + RadrootsNostrSignerAuthChallenge::new("not-a-url", 31).expect_err("invalid challenge"); + assert!(invalid_challenge.to_string().contains("invalid auth url")); + + let empty_challenge = + RadrootsNostrSignerAuthChallenge::new(" ", 31).expect_err("empty challenge"); + assert!(empty_challenge.to_string().contains("invalid auth url")); + } + + #[test] + fn auth_challenge_deserialize_rejects_invalid_urls_across_entrypoints() { + let invalid_json = json!({ + "auth_url": " ", + "required_at_unix": 44 + }); + + let invalid_from_value = + serde_json::from_value::<RadrootsNostrSignerAuthChallenge>(invalid_json.clone()) + .expect_err("invalid auth challenge from value"); + assert!(invalid_from_value.to_string().contains("invalid auth url")); + + let invalid_from_str = + serde_json::from_str::<RadrootsNostrSignerAuthChallenge>(&invalid_json.to_string()) + .expect_err("invalid auth challenge from str"); + assert!(invalid_from_str.to_string().contains("invalid auth url")); + + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("invalid-auth-challenge.json"); + std::fs::write( + &path, + serde_json::to_vec(&invalid_json).expect("serialize invalid auth challenge"), + ) + .expect("write invalid auth challenge"); + let file = std::fs::File::open(&path).expect("open invalid auth challenge"); + let reader = std::io::BufReader::new(file); + let invalid_from_reader = + serde_json::from_reader::<_, RadrootsNostrSignerAuthChallenge>(reader) + .expect_err("invalid auth challenge from reader"); + assert!(invalid_from_reader.to_string().contains("invalid auth url")); + + let invalid_shape_json = json!({ + "auth_url": 1, + "required_at_unix": 44 + }); + let invalid_shape_from_value = + serde_json::from_value::<RadrootsNostrSignerAuthChallenge>(invalid_shape_json.clone()) + .expect_err("invalid auth challenge shape from value"); + assert!( + invalid_shape_from_value + .to_string() + .contains("invalid type") + ); + + let invalid_shape_from_str = serde_json::from_str::<RadrootsNostrSignerAuthChallenge>( + &invalid_shape_json.to_string(), + ) + .expect_err("invalid auth challenge shape from str"); + assert!(invalid_shape_from_str.to_string().contains("invalid type")); + + let invalid_shape_path = temp.path().join("invalid-auth-challenge-shape.json"); + std::fs::write( + &invalid_shape_path, + serde_json::to_vec(&invalid_shape_json) + .expect("serialize invalid auth challenge shape"), + ) + .expect("write invalid auth challenge shape"); + let invalid_shape_file = + std::fs::File::open(&invalid_shape_path).expect("open invalid auth challenge shape"); + let invalid_shape_reader = std::io::BufReader::new(invalid_shape_file); + let invalid_shape_from_reader = + serde_json::from_reader::<_, RadrootsNostrSignerAuthChallenge>(invalid_shape_reader) + .expect_err("invalid auth challenge shape from reader"); + assert!( + invalid_shape_from_reader + .to_string() + .contains("invalid type") + ); + } + + #[test] + fn connection_record_serde_migrates_legacy_connect_secret_and_validates_new_fields() { + let record_json = json!({ + "connection_id": "conn-legacy", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000009").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000010"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000011"), + "connect_secret": " legacy-secret ", + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "status": "Active", + "status_reason": null, + "created_at_unix": 1, + "updated_at_unix": 1, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + }); + + let decoded_without_secret: RadrootsNostrSignerConnectionRecord = serde_json::from_value( + json!({ + "connection_id": "conn-no-secret", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000008").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000007"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000006"), + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "status": "Active", + "created_at_unix": 0, + "updated_at_unix": 0, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + }), + ) + .expect("deserialize record without secret"); + assert!(decoded_without_secret.connect_secret_hash.is_none()); + + let decoded_with_null_secret: RadrootsNostrSignerConnectionRecord = serde_json::from_value( + json!({ + "connection_id": "conn-null-secret", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000005").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000004"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000003"), + "connect_secret_hash": null, + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "status": "Active", + "created_at_unix": 0, + "updated_at_unix": 0, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + }), + ) + .expect("deserialize record with null secret"); + assert!(decoded_with_null_secret.connect_secret_hash.is_none()); + + let decoded: RadrootsNostrSignerConnectionRecord = + serde_json::from_value(record_json).expect("deserialize legacy record"); + assert!( + decoded + .connect_secret_hash + .as_ref() + .expect("connect secret hash") + .matches_secret("legacy-secret") + ); + + let encoded = serde_json::to_value(&decoded).expect("serialize record"); + assert!(encoded.get("connect_secret").is_none()); + assert!(encoded.get("connect_secret_hash").is_some()); + assert_eq!( + encoded + .get("auth_state") + .and_then(serde_json::Value::as_str), + Some("NotRequired") + ); + + let valid_hash = RadrootsNostrSignerConnectSecretHash::from_secret("explicit-secret") + .expect("valid hash"); + let decoded_new_format: RadrootsNostrSignerConnectionRecord = serde_json::from_value( + json!({ + "connection_id": "conn-new", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000015").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000016"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000017"), + "connect_secret_hash": { + "algorithm": "sha256", + "digest_hex": valid_hash.digest_hex + }, + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "status": "Active", + "created_at_unix": 3, + "updated_at_unix": 3, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + }), + ) + .expect("deserialize new-format record"); + assert!( + decoded_new_format + .connect_secret_hash + .as_ref() + .expect("new-format hash") + .matches_secret("explicit-secret") + ); + + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("connection-record.json"); + let reader_json = json!({ + "connection_id": "conn-reader", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000021").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000022"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000023"), + "connect_secret_hash": { + "algorithm": "sha256", + "digest_hex": RadrootsNostrSignerConnectSecretHash::from_secret("reader-secret") + .expect("reader hash") + .digest_hex + }, + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "auth_state": "Pending", + "auth_challenge": { + "auth_url": "https://auth.example/reader", + "required_at_unix": 5 + }, + "status": "Active", + "created_at_unix": 5, + "updated_at_unix": 5, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + }); + std::fs::write( + &path, + serde_json::to_vec(&reader_json).expect("serialize reader json"), + ) + .expect("write reader json"); + let file = std::fs::File::open(&path).expect("open reader json"); + let reader = std::io::BufReader::new(file); + let decoded_from_reader: RadrootsNostrSignerConnectionRecord = + serde_json::from_reader(reader).expect("deserialize reader record"); + assert!( + decoded_from_reader + .connect_secret_hash + .as_ref() + .expect("reader hash") + .matches_secret("reader-secret") + ); + assert_eq!( + decoded_from_reader + .auth_challenge + .as_ref() + .expect("reader auth challenge") + .auth_url, + "https://auth.example/reader" + ); + + let invalid_hash_json = json!({ + "connection_id": "conn-invalid", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000012").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000013"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000014"), + "connect_secret_hash": { + "algorithm": "sha256", + "digest_hex": "not-hex" + }, + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "status": "Active", + "auth_state": "Authorized", + "auth_challenge": { + "auth_url": "https://auth.example", + "required_at_unix": 2 + }, + "status_reason": null, + "created_at_unix": 2, + "updated_at_unix": 2, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + }); + let invalid_hash = + serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(invalid_hash_json) + .expect_err("invalid hash"); + assert!( + invalid_hash + .to_string() + .contains("invalid connect secret digest") + ); + + let invalid_nonhex_hash = serde_json::from_value::<RadrootsNostrSignerConnectionRecord>( + json!({ + "connection_id": "conn-invalid-nonhex", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000018").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000019"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000020"), + "connect_secret_hash": { + "algorithm": "sha256", + "digest_hex": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + }, + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "status": "Active", + "created_at_unix": 4, + "updated_at_unix": 4, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + }), + ) + .expect_err("invalid nonhex hash"); + assert!( + invalid_nonhex_hash + .to_string() + .contains("invalid connect secret digest") + ); + + let invalid_connect_secret_hash_type = + serde_json::from_value::<RadrootsNostrSignerConnectionRecord>(json!({ + "connection_id": "conn-invalid-type", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000024").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000025"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000026"), + "connect_secret_hash": 7, + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "status": "Active", + "created_at_unix": 6, + "updated_at_unix": 6, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + })) + .expect_err("invalid connect secret hash type"); + assert!(!invalid_connect_secret_hash_type.to_string().is_empty()); + + let invalid_connect_secret_hash_path = temp.path().join("invalid-connect-secret-type.json"); + std::fs::write( + &invalid_connect_secret_hash_path, + serde_json::to_vec(&json!({ + "connection_id": "conn-invalid-type-reader", + "client_public_key": public_key("0000000000000000000000000000000000000000000000000000000000000027").to_hex(), + "signer_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000028"), + "user_identity": public_identity("0000000000000000000000000000000000000000000000000000000000000029"), + "connect_secret_hash": 9, + "requested_permissions": "", + "granted_permissions": [], + "relays": [], + "approval_requirement": "NotRequired", + "approval_state": "NotRequired", + "status": "Active", + "created_at_unix": 7, + "updated_at_unix": 7, + "last_authenticated_at_unix": null, + "last_request_at_unix": null + })) + .expect("serialize invalid connect secret hash type"), + ) + .expect("write invalid connect secret hash type"); + let invalid_connect_secret_hash_file = + std::fs::File::open(&invalid_connect_secret_hash_path) + .expect("open invalid connect secret hash type"); + let invalid_connect_secret_hash_reader = + std::io::BufReader::new(invalid_connect_secret_hash_file); + let invalid_connect_secret_hash_from_reader = serde_json::from_reader::< + _, + RadrootsNostrSignerConnectionRecord, + >(invalid_connect_secret_hash_reader) + .expect_err("invalid connect secret hash type from reader"); + assert!( + !invalid_connect_secret_hash_from_reader + .to_string() + .is_empty() + ); } #[test]