lib

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

commit 41d2a1e29f1a8447c424bc7558a5b775b337accc
parent 8a56141399b3e760162f9dd7e49c1e11a265c7f8
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 01:18:49 +0000

signer: restore pending auth challenge on replay failure

- add signer record support for restoring a pending auth challenge after authorization
- expose a manager transition to requeue the pending request when replay delivery fails
- cover the restored pending-auth path in signer model and manager tests
- validate with cargo test -p radroots-nostr-signer and cargo fmt --all --check

Diffstat:
Mcrates/nostr-signer/src/manager.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr-signer/src/model.rs | 39+++++++++++++++++++++++++++++++++++++++
2 files changed, 120 insertions(+), 0 deletions(-)

diff --git a/crates/nostr-signer/src/manager.rs b/crates/nostr-signer/src/manager.rs @@ -492,6 +492,36 @@ impl RadrootsNostrSignerManager { }) } + pub fn restore_pending_auth_challenge( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + pending_request: RadrootsNostrSignerPendingRequest, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let restored_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + if record.is_terminal() { + return Err(RadrootsNostrSignerError::InvalidState(format!( + "cannot restore auth challenge for {} connection", + status_label(record.status) + ))); + } + if record.auth_state != RadrootsNostrSignerAuthState::Authorized { + return Err(RadrootsNostrSignerError::InvalidState( + "auth challenge not authorized for connection".into(), + )); + } + if record.auth_challenge.is_none() { + return Err(RadrootsNostrSignerError::InvalidState( + "auth challenge missing for connection".into(), + )); + } + + record.restore_pending_auth_challenge(pending_request, restored_at_unix); + Ok(record.clone()) + }) + } + pub fn mark_authenticated( &self, connection_id: &RadrootsNostrSignerConnectionId, @@ -1712,6 +1742,57 @@ mod tests { } #[test] + fn restored_authorized_auth_challenge_requeues_pending_request() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000134", + )) + .expect("set signer"); + let record = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000135"), + public_identity("0000000000000000000000000000000000000000000000000000000000000136"), + )) + .expect("register"); + + manager + .require_auth_challenge(&record.connection_id, "https://auth.example/flow") + .expect("require auth"); + manager + .set_pending_request(&record.connection_id, request_message("req-replay")) + .expect("set pending"); + + let authorized = manager + .authorize_auth_challenge(&record.connection_id) + .expect("authorize"); + let pending_request = authorized.pending_request.expect("pending request"); + + let restored = manager + .restore_pending_auth_challenge(&record.connection_id, pending_request.clone()) + .expect("restore pending challenge"); + assert_eq!(restored.auth_state, RadrootsNostrSignerAuthState::Pending); + assert_eq!( + restored + .auth_challenge + .as_ref() + .expect("challenge") + .authorized_at_unix, + None + ); + assert!(restored.last_authenticated_at_unix.is_none()); + assert_eq!( + restored + .pending_request + .as_ref() + .expect("pending request") + .request_id() + .as_str(), + pending_request.request_id().as_str() + ); + } + + #[test] fn connect_secret_consumption_persists_and_remains_idempotent() { let manager = RadrootsNostrSignerManager::new_in_memory(); manager diff --git a/crates/nostr-signer/src/model.rs b/crates/nostr-signer/src/model.rs @@ -541,6 +541,22 @@ impl RadrootsNostrSignerConnectionRecord { self.updated_at_unix = authorized_at_unix; self.pending_request.take() } + + pub fn restore_pending_auth_challenge( + &mut self, + pending_request: RadrootsNostrSignerPendingRequest, + restored_at_unix: u64, + ) { + self.auth_state = RadrootsNostrSignerAuthState::Pending; + if let Some(auth_challenge) = self.auth_challenge.as_mut() { + let previous_authorized_at_unix = auth_challenge.authorized_at_unix.take(); + if self.last_authenticated_at_unix == previous_authorized_at_unix { + self.last_authenticated_at_unix = None; + } + } + self.pending_request = Some(pending_request); + self.updated_at_unix = restored_at_unix; + } } impl RadrootsNostrSignerRequestAuditRecord { @@ -787,6 +803,29 @@ mod tests { assert_eq!(record.last_request_at_unix, Some(16)); assert_eq!(replay.request_id().as_str(), "req-1"); assert!(no_challenge_replay.is_none()); + + record.restore_pending_auth_challenge(replay, 23); + + assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Pending); + assert_eq!( + record + .auth_challenge + .as_ref() + .expect("restored challenge") + .authorized_at_unix, + None + ); + assert_eq!(record.last_authenticated_at_unix, None); + assert_eq!(record.updated_at_unix, 23); + assert_eq!( + record + .pending_request + .as_ref() + .expect("restored pending request") + .request_id() + .as_str(), + "req-1" + ); } #[test]