lib

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

commit 011033d4b7b6efbb85edf705a8eb74b6c3cdce77
parent 3c1f63f9cc44f9e71d330e7ed29bf09a377419e8
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 00:33:32 +0000

nostr-signer: persist connect secret consumption

- add consumed-at tracking and helper methods to signer connection records and serde state
- expose a manager API that marks one-shot connect secrets consumed after successful handshake publish
- keep consumed secrets non-reusable even after terminal state while preserving unconsumed terminal reuse
- validate with cargo test -p radroots-nostr-signer and cargo fmt --all --check; nix run .#contract is blocked by unrelated staged simplex workspace drift

Diffstat:
Mcrates/nostr-signer/src/manager.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/nostr-signer/src/model.rs | 33+++++++++++++++++++++++++++++++++
2 files changed, 137 insertions(+), 5 deletions(-)

diff --git a/crates/nostr-signer/src/manager.rs b/crates/nostr-signer/src/manager.rs @@ -135,8 +135,8 @@ impl RadrootsNostrSignerManager { .connections .iter() .find(|record| { - !record.is_terminal() - && record.connect_secret_hash.as_ref() == Some(&connect_secret_hash) + record.connect_secret_hash.as_ref() == Some(&connect_secret_hash) + && (!record.is_terminal() || record.connect_secret_is_consumed()) }) .cloned()) } @@ -248,8 +248,8 @@ impl RadrootsNostrSignerManager { .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_hash.as_ref() == Some(secret_hash) + record.connect_secret_hash.as_ref() == Some(secret_hash) + && (!record.is_terminal() || record.connect_secret_is_consumed()) }) { return Err(RadrootsNostrSignerError::ConnectSecretAlreadyInUse); } @@ -504,6 +504,23 @@ impl RadrootsNostrSignerManager { }) } + pub fn mark_connect_secret_consumed( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> { + self.update_state_with(|state| { + let consumed_at_unix = now_unix_secs(); + let record = find_connection_mut(state, connection_id)?; + if record.connect_secret_hash.is_none() { + return Err(RadrootsNostrSignerError::InvalidState( + "connection does not have a connect secret".into(), + )); + } + record.mark_connect_secret_consumed(consumed_at_unix); + Ok(record.clone()) + }) + } + pub fn evaluate_request( &self, connection_id: &RadrootsNostrSignerConnectionId, @@ -967,6 +984,10 @@ mod tests { 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_hash, right.connect_secret_hash); + assert_eq!( + left.connect_secret_consumed_at_unix, + right.connect_secret_consumed_at_unix + ); assert_eq!(left.requested_permissions, right.requested_permissions); assert_eq!(left.granted_permissions, right.granted_permissions); assert_eq!(left.relays, right.relays); @@ -1540,6 +1561,15 @@ mod tests { .expect("auth"); assert!(authenticated.last_authenticated_at_unix.is_some()); + let consumed = manager + .mark_connect_secret_consumed(&record.connection_id) + .expect_err("consume missing secret"); + assert!( + consumed + .to_string() + .contains("connection does not have a connect secret") + ); + let audit = manager .record_request( &record.connection_id, @@ -1682,6 +1712,51 @@ mod tests { } #[test] + fn connect_secret_consumption_persists_and_remains_idempotent() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000037", + )) + .expect("set signer"); + let record = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000038"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000039", + ), + ) + .with_connect_secret("one-shot-secret"), + ) + .expect("register"); + + let consumed = manager + .mark_connect_secret_consumed(&record.connection_id) + .expect("consume secret"); + assert!(consumed.connect_secret_is_consumed()); + assert!(consumed.connect_secret_consumed_at_unix.is_some()); + + let consumed_again = manager + .mark_connect_secret_consumed(&record.connection_id) + .expect("consume secret again"); + assert_eq!( + consumed_again.connect_secret_consumed_at_unix, + consumed.connect_secret_consumed_at_unix + ); + + let found = manager + .find_connection_by_connect_secret("one-shot-secret") + .expect("find consumed secret") + .expect("stored secret"); + assert!(found.connect_secret_is_consumed()); + assert_eq!( + found.connect_secret_consumed_at_unix, + consumed.connect_secret_consumed_at_unix + ); + } + + #[test] fn manager_reports_missing_connections_and_save_failures() { let manager = RadrootsNostrSignerManager::new_in_memory(); let missing_id = RadrootsNostrSignerConnectionId::parse("missing").expect("id"); @@ -2017,7 +2092,7 @@ mod tests { } #[test] - fn helpers_cover_status_labels_and_terminal_secret_reuse() { + fn helpers_cover_status_labels_and_consumed_secret_reuse_rules() { assert_eq!( status_label(RadrootsNostrSignerConnectionStatus::Pending), "pending" @@ -2077,6 +2152,30 @@ mod tests { .expect("connect secret hash") .matches_secret("reusable-secret") ); + + let consumed = manager + .mark_connect_secret_consumed(&reused.connection_id) + .expect("consume secret"); + assert!(consumed.connect_secret_is_consumed()); + manager + .reject_connection(&reused.connection_id, Some("closed".into())) + .expect("reject consumed"); + + let blocked_reuse = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000047"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000048", + ), + ) + .with_connect_secret("reusable-secret"), + ) + .expect_err("block consumed secret reuse"); + assert!(matches!( + blocked_reuse, + RadrootsNostrSignerError::ConnectSecretAlreadyInUse + )); } #[test] diff --git a/crates/nostr-signer/src/model.rs b/crates/nostr-signer/src/model.rs @@ -122,6 +122,8 @@ pub struct RadrootsNostrSignerConnectionRecord { skip_serializing_if = "Option::is_none" )] pub connect_secret_hash: Option<RadrootsNostrSignerConnectSecretHash>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_secret_consumed_at_unix: Option<u64>, pub requested_permissions: RadrootsNostrConnectPermissions, #[serde(default)] pub granted_permissions: Vec<RadrootsNostrSignerPermissionGrant>, @@ -444,6 +446,7 @@ impl RadrootsNostrSignerConnectionRecord { .connect_secret .as_deref() .and_then(RadrootsNostrSignerConnectSecretHash::from_secret), + connect_secret_consumed_at_unix: None, requested_permissions: draft.requested_permissions, granted_permissions: Vec::new(), relays: draft.relays, @@ -488,6 +491,10 @@ impl RadrootsNostrSignerConnectionRecord { ) } + pub fn connect_secret_is_consumed(&self) -> bool { + self.connect_secret_hash.is_some() && self.connect_secret_consumed_at_unix.is_some() + } + pub fn touch_updated(&mut self, updated_at_unix: u64) { self.updated_at_unix = updated_at_unix; } @@ -502,6 +509,14 @@ impl RadrootsNostrSignerConnectionRecord { self.updated_at_unix = request_at_unix; } + pub fn mark_connect_secret_consumed(&mut self, consumed_at_unix: u64) { + if self.connect_secret_hash.is_none() || self.connect_secret_consumed_at_unix.is_some() { + return; + } + self.connect_secret_consumed_at_unix = Some(consumed_at_unix); + self.updated_at_unix = consumed_at_unix; + } + pub fn require_auth_challenge(&mut self, auth_challenge: RadrootsNostrSignerAuthChallenge) { self.auth_state = RadrootsNostrSignerAuthState::Pending; self.auth_challenge = Some(auth_challenge.clone()); @@ -728,11 +743,13 @@ mod tests { .expect("connect secret hash") .matches_secret("secret") ); + assert!(!record.connect_secret_is_consumed()); assert!(!record.is_terminal()); record.touch_updated(12); record.mark_authenticated(14); record.mark_request(16); + record.mark_connect_secret_consumed(17); record.require_auth_challenge( RadrootsNostrSignerAuthChallenge::new("https://auth.example/path", 18) .expect("auth challenge"), @@ -754,6 +771,8 @@ mod tests { .authorize_auth_challenge(25); assert_eq!(record.updated_at_unix, 22); + assert_eq!(record.connect_secret_consumed_at_unix, Some(17)); + assert!(record.connect_secret_is_consumed()); assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::Authorized); assert_eq!( record @@ -1035,6 +1054,11 @@ mod tests { ) .expect("deserialize record without secret"); assert!(decoded_without_secret.connect_secret_hash.is_none()); + assert!( + decoded_without_secret + .connect_secret_consumed_at_unix + .is_none() + ); let decoded_with_null_secret: RadrootsNostrSignerConnectionRecord = serde_json::from_value( json!({ @@ -1057,6 +1081,11 @@ mod tests { ) .expect("deserialize record with null secret"); assert!(decoded_with_null_secret.connect_secret_hash.is_none()); + assert!( + decoded_with_null_secret + .connect_secret_consumed_at_unix + .is_none() + ); let decoded: RadrootsNostrSignerConnectionRecord = serde_json::from_value(record_json).expect("deserialize legacy record"); @@ -1071,6 +1100,7 @@ mod tests { 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!(encoded.get("connect_secret_consumed_at_unix").is_none()); assert_eq!( encoded .get("auth_state") @@ -1090,6 +1120,7 @@ mod tests { "algorithm": "sha256", "digest_hex": valid_hash.digest_hex }, + "connect_secret_consumed_at_unix": 23, "requested_permissions": "", "granted_permissions": [], "relays": [], @@ -1110,6 +1141,8 @@ mod tests { .expect("new-format hash") .matches_secret("explicit-secret") ); + assert_eq!(decoded_new_format.connect_secret_consumed_at_unix, Some(23)); + assert!(decoded_new_format.connect_secret_is_consumed()); let temp = tempdir().expect("tempdir"); let path = temp.path().join("connection-record.json");