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