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