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:
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");