myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 0a5e1cf3197960cddabfd1fe0a9831ee63778698
parent 506e299aed7867d7fdebfbe5d73cf775cc50c865
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 01:33:36 +0000

policy: add rate limits and stale session cleanup

Diffstat:
M.env.example | 6++++++
Msrc/app/runtime.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/config.rs | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/policy.rs | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/transport/nip46.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 526 insertions(+), 8 deletions(-)

diff --git a/.env.example b/.env.example @@ -67,6 +67,12 @@ MYC_POLICY_AUTH_PENDING_TTL_SECS=900 # set these when automatic auth challenge policy should expire trusted sessions # MYC_POLICY_AUTHORIZED_TTL_SECS=3600 # MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 +# optional per-client connect attempt throttle +# MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS=60 +# MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS=5 +# optional per-client automatic auth challenge issuance throttle +# MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS=120 +# MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS=3 MYC_TRANSPORT_ENABLED=true MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10 diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -1068,6 +1068,13 @@ impl MycSignerContext { Some(_) => manager.set_signer_identity(configured_public.clone())?, None => manager.set_signer_identity(configured_public.clone())?, } + let stale_session_cleanup_count = policy.cleanup_stale_sessions(&manager)?; + if stale_session_cleanup_count > 0 { + tracing::info!( + stale_session_cleanup_count, + "cleaned stale trusted auth sessions during myc bootstrap" + ); + } Ok(Self { signer_identity_provider, @@ -1166,12 +1173,13 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; + use nostr::PublicKey; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKind}; use radroots_nostr_signer::prelude::{ RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement, - RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerManager, - RadrootsNostrSqliteSignerStore, + RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerManager, RadrootsNostrSqliteSignerStore, }; use super::MycRuntime; @@ -1332,6 +1340,64 @@ mod tests { } #[test] + fn bootstrap_cleans_stale_trusted_authorized_sessions() { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.path().join("state"); + config.paths.signer_identity_path = temp.path().join("signer.json"); + config.paths.user_identity_path = temp.path().join("user.json"); + config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); + config.policy.auth_authorized_ttl_secs = Some(1); + let client_public_key = + PublicKey::parse("4545454545454545454545454545454545454545454545454545454545454545") + .expect("client public key"); + config.policy.trusted_client_pubkeys = vec![client_public_key.to_hex()]; + write_test_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_test_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + + let runtime = MycRuntime::bootstrap(config.clone()).expect("runtime"); + let manager = runtime.signer_manager().expect("manager"); + let connection = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + client_public_key, + runtime.user_public_identity(), + ) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), + ) + .expect("register connection"); + manager + .require_auth_challenge( + &connection.connection_id, + config.policy.auth_url.as_deref().expect("auth url"), + ) + .expect("require auth"); + manager + .authorize_auth_challenge(&connection.connection_id) + .expect("authorize auth"); + + std::thread::sleep(std::time::Duration::from_secs(2)); + drop(runtime); + + let runtime = MycRuntime::bootstrap(config).expect("runtime restart"); + let reloaded = runtime + .signer_manager() + .expect("manager") + .get_connection(&connection.connection_id) + .expect("load connection") + .expect("connection"); + + assert_eq!(reloaded.auth_state, RadrootsNostrSignerAuthState::Pending); + assert!(reloaded.auth_challenge.is_some()); + } + + #[test] fn bootstrap_prepares_transport_when_enabled() { let temp = tempfile::tempdir().expect("tempdir"); let mut config = MycConfig::default(); diff --git a/src/config.rs b/src/config.rs @@ -185,6 +185,10 @@ pub struct MycPolicyConfig { pub auth_pending_ttl_secs: u64, pub auth_authorized_ttl_secs: Option<u64>, pub reauth_after_inactivity_secs: Option<u64>, + pub connect_rate_limit_window_secs: Option<u64>, + pub connect_rate_limit_max_attempts: Option<usize>, + pub auth_challenge_rate_limit_window_secs: Option<u64>, + pub auth_challenge_rate_limit_max_attempts: Option<usize>, } impl Default for MycConfig { @@ -328,6 +332,10 @@ impl Default for MycPolicyConfig { auth_pending_ttl_secs: 900, auth_authorized_ttl_secs: None, reauth_after_inactivity_secs: None, + connect_rate_limit_window_secs: None, + connect_rate_limit_max_attempts: None, + auth_challenge_rate_limit_window_secs: None, + auth_challenge_rate_limit_max_attempts: None, } } } @@ -712,6 +720,26 @@ impl MycConfig { "MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS", self.policy.reauth_after_inactivity_secs, ); + push_optional_u64_env_line( + &mut lines, + "MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS", + self.policy.connect_rate_limit_window_secs, + ); + push_optional_usize_env_line( + &mut lines, + "MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS", + self.policy.connect_rate_limit_max_attempts, + ); + push_optional_u64_env_line( + &mut lines, + "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS", + self.policy.auth_challenge_rate_limit_window_secs, + ); + push_optional_usize_env_line( + &mut lines, + "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS", + self.policy.auth_challenge_rate_limit_max_attempts, + ); push_env_line( &mut lines, "MYC_TRANSPORT_ENABLED", @@ -880,6 +908,16 @@ impl MycConfig { .to_owned(), )); } + validate_optional_rate_limit( + "policy.connect_rate_limit", + self.policy.connect_rate_limit_window_secs, + self.policy.connect_rate_limit_max_attempts, + )?; + validate_optional_rate_limit( + "policy.auth_challenge_rate_limit", + self.policy.auth_challenge_rate_limit_window_secs, + self.policy.auth_challenge_rate_limit_max_attempts, + )?; let trusted_client_pubkeys = normalize_policy_client_pubkeys(&self.policy.trusted_client_pubkeys)?; @@ -1192,6 +1230,22 @@ fn apply_env_entry( config.policy.reauth_after_inactivity_secs = Some(parse_u64_env(key, value, path, line_number)?); } + "MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS" => { + config.policy.connect_rate_limit_window_secs = + Some(parse_u64_env(key, value, path, line_number)?); + } + "MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS" => { + config.policy.connect_rate_limit_max_attempts = + Some(parse_usize_env(key, value, path, line_number)?); + } + "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS" => { + config.policy.auth_challenge_rate_limit_window_secs = + Some(parse_u64_env(key, value, path, line_number)?); + } + "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS" => { + config.policy.auth_challenge_rate_limit_max_attempts = + Some(parse_usize_env(key, value, path, line_number)?); + } "MYC_TRANSPORT_ENABLED" => { config.transport.enabled = parse_bool_env(key, value, path, line_number)?; } @@ -1429,6 +1483,32 @@ fn parse_u16_list_env( .collect() } +fn validate_optional_rate_limit( + label: &str, + window_secs: Option<u64>, + max_attempts: Option<usize>, +) -> Result<(), MycError> { + match (window_secs, max_attempts) { + (None, None) => Ok(()), + (Some(window_secs), Some(max_attempts)) => { + if window_secs == 0 { + return Err(MycError::InvalidConfig(format!( + "{label}.window_secs must be greater than zero when set" + ))); + } + if max_attempts == 0 { + return Err(MycError::InvalidConfig(format!( + "{label}.max_attempts must be greater than zero when set" + ))); + } + Ok(()) + } + _ => Err(MycError::InvalidConfig(format!( + "{label}.window_secs and {label}.max_attempts must be set together" + ))), + } +} + fn normalize_policy_client_pubkeys(values: &[String]) -> Result<BTreeSet<String>, MycError> { values .iter() @@ -1810,6 +1890,10 @@ mod tests { assert_eq!(config.policy.auth_pending_ttl_secs, 900); assert_eq!(config.policy.auth_authorized_ttl_secs, None); assert_eq!(config.policy.reauth_after_inactivity_secs, None); + assert_eq!(config.policy.connect_rate_limit_window_secs, None); + assert_eq!(config.policy.connect_rate_limit_max_attempts, None); + assert_eq!(config.policy.auth_challenge_rate_limit_window_secs, None); + assert_eq!(config.policy.auth_challenge_rate_limit_max_attempts, None); assert_eq!(config.audit.default_read_limit, 200); assert_eq!(config.audit.max_active_file_bytes, 262_144); assert_eq!(config.audit.max_archived_files, 8); @@ -1884,6 +1968,10 @@ MYC_POLICY_AUTH_URL=https://auth.example.com/challenge MYC_POLICY_AUTH_PENDING_TTL_SECS=300 MYC_POLICY_AUTHORIZED_TTL_SECS=3600 MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 +MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS=60 +MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS=5 +MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS=120 +MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS=3 MYC_TRANSPORT_ENABLED=true MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15 MYC_TRANSPORT_RELAYS=wss://relay.example.com,wss://relay2.example.com @@ -1992,6 +2080,16 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 assert_eq!(config.policy.auth_pending_ttl_secs, 300); assert_eq!(config.policy.auth_authorized_ttl_secs, Some(3600)); assert_eq!(config.policy.reauth_after_inactivity_secs, Some(600)); + assert_eq!(config.policy.connect_rate_limit_window_secs, Some(60)); + assert_eq!(config.policy.connect_rate_limit_max_attempts, Some(5)); + assert_eq!( + config.policy.auth_challenge_rate_limit_window_secs, + Some(120) + ); + assert_eq!( + config.policy.auth_challenge_rate_limit_max_attempts, + Some(3) + ); assert!(config.transport.enabled); assert_eq!(config.transport.connect_timeout_secs, 15); assert_eq!( @@ -2185,6 +2283,25 @@ MYC_UNKNOWN=nope } #[test] + fn validate_requires_complete_rate_limit_pairs() { + let mut config = MycConfig::default(); + config.policy.connect_rate_limit_window_secs = Some(60); + + let err = config + .validate() + .expect_err("incomplete connect rate limit"); + assert!(err.to_string().contains("policy.connect_rate_limit")); + + let mut config = MycConfig::default(); + config.policy.auth_challenge_rate_limit_max_attempts = Some(2); + + let err = config + .validate() + .expect_err("incomplete auth challenge rate limit"); + assert!(err.to_string().contains("policy.auth_challenge_rate_limit")); + } + + #[test] fn parse_and_validate_os_keyring_identity_backends() { let config = MycConfig::from_env_str( r#" @@ -2376,6 +2493,10 @@ MYC_POLICY_AUTH_URL=https://auth.example.com/challenge MYC_POLICY_AUTH_PENDING_TTL_SECS=300 MYC_POLICY_AUTHORIZED_TTL_SECS=3600 MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 +MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS=60 +MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS=5 +MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS=120 +MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS=3 MYC_TRANSPORT_ENABLED=true MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15 MYC_TRANSPORT_RELAYS=wss://relay.example.com,wss://relay2.example.com diff --git a/src/policy.rs b/src/policy.rs @@ -1,4 +1,5 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use nostr::PublicKey; @@ -32,6 +33,15 @@ pub struct MycPolicyContext { auth_pending_ttl_secs: u64, auth_authorized_ttl_secs: Option<u64>, reauth_after_inactivity_secs: Option<u64>, + connect_rate_limiter: Option<MycPolicyRateLimiter>, + auth_challenge_rate_limiter: Option<MycPolicyRateLimiter>, +} + +#[derive(Debug, Clone)] +struct MycPolicyRateLimiter { + window_secs: u64, + max_attempts: usize, + entries: Arc<Mutex<HashMap<String, VecDeque<u64>>>>, } impl MycPolicyContext { @@ -50,6 +60,14 @@ impl MycPolicyContext { auth_pending_ttl_secs: config.auth_pending_ttl_secs, auth_authorized_ttl_secs: config.auth_authorized_ttl_secs, reauth_after_inactivity_secs: config.reauth_after_inactivity_secs, + connect_rate_limiter: build_rate_limiter( + config.connect_rate_limit_window_secs, + config.connect_rate_limit_max_attempts, + ), + auth_challenge_rate_limiter: build_rate_limiter( + config.auth_challenge_rate_limit_window_secs, + config.auth_challenge_rate_limit_max_attempts, + ), }) } @@ -86,6 +104,17 @@ impl MycPolicyContext { } } + pub fn connect_rate_limit_denied_reason( + &self, + client_public_key: &PublicKey, + ) -> Option<String> { + self.connect_rate_limiter.as_ref().and_then(|limiter| { + limiter + .check_and_record(&client_public_key.to_hex()) + .map(|retry_after_secs| throttled_reason("connect attempts", retry_after_secs)) + }) + } + pub fn auto_granted_permissions( &self, requested_permissions: &RadrootsNostrConnectPermissions, @@ -165,14 +194,22 @@ impl MycPolicyContext { && self.auth_challenge_is_expired(connection) { if self.request_uses_automatic_auth(connection, &request_message.request) { - manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; + if let Some(reason) = + self.require_auth_challenge_with_guardrails(manager, connection)? + { + return Ok(Some(reason)); + } } else { return Ok(Some( "auth challenge expired; require a new auth challenge".to_owned(), )); } } else if self.should_require_fresh_auth(connection, &request_message.request) { - manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; + if let Some(reason) = + self.require_auth_challenge_with_guardrails(manager, connection)? + { + return Ok(Some(reason)); + } } Ok(None) @@ -193,6 +230,21 @@ impl MycPolicyContext { Ok(()) } + pub fn cleanup_stale_sessions( + &self, + manager: &RadrootsNostrSignerManager, + ) -> Result<usize, MycError> { + let mut cleaned = 0usize; + for connection in manager.list_connections()? { + if !self.stale_session_requires_cleanup(&connection) { + continue; + } + self.require_auth_challenge(manager, &connection)?; + cleaned += 1; + } + Ok(cleaned) + } + pub fn record_policy_denied_request( &self, manager: &RadrootsNostrSignerManager, @@ -271,9 +323,7 @@ impl MycPolicyContext { connection: &RadrootsNostrSignerConnectionRecord, request: &RadrootsNostrConnectRequest, ) -> bool { - self.auth_url.is_some() - && self.client_is_trusted(&connection.client_public_key) - && request_requires_auth(request) + self.automatic_auth_enabled_for_connection(connection) && request_requires_auth(request) } fn should_require_fresh_auth( @@ -328,6 +378,102 @@ impl MycPolicyContext { ) }) } + + fn automatic_auth_enabled_for_connection( + &self, + connection: &RadrootsNostrSignerConnectionRecord, + ) -> bool { + self.auth_url.is_some() && self.client_is_trusted(&connection.client_public_key) + } + + fn require_auth_challenge_with_guardrails( + &self, + manager: &RadrootsNostrSignerManager, + connection: &RadrootsNostrSignerConnectionRecord, + ) -> Result<Option<String>, MycError> { + if let Some(retry_after_secs) = self + .auth_challenge_rate_limiter + .as_ref() + .and_then(|limiter| limiter.check_and_record(&connection.client_public_key.to_hex())) + { + return Ok(Some(throttled_reason( + "auth challenge issuance", + retry_after_secs, + ))); + } + self.require_auth_challenge(manager, connection)?; + Ok(None) + } + + fn require_auth_challenge( + &self, + manager: &RadrootsNostrSignerManager, + connection: &RadrootsNostrSignerConnectionRecord, + ) -> Result<(), MycError> { + manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; + Ok(()) + } + + fn stale_session_requires_cleanup( + &self, + connection: &RadrootsNostrSignerConnectionRecord, + ) -> bool { + if connection.is_terminal() + || connection.auth_state + != radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Authorized + || !self.automatic_auth_enabled_for_connection(connection) + { + return false; + } + + let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else { + return true; + }; + let now_unix = now_unix_secs(); + + if self + .auth_authorized_ttl_secs + .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl)) + { + return true; + } + + self.reauth_after_inactivity_secs.is_some_and(|ttl| { + connection + .last_request_at_unix + .is_some_and(|last_request_at_unix| { + now_unix > last_request_at_unix.saturating_add(ttl) + }) + }) + } +} + +impl MycPolicyRateLimiter { + fn check_and_record(&self, key: &str) -> Option<u64> { + let now_unix = now_unix_secs(); + let mut guard = self + .entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let attempts = guard.entry(key.to_owned()).or_default(); + prune_attempts(attempts, now_unix, self.window_secs); + if attempts.len() >= self.max_attempts { + return Some( + attempts + .front() + .copied() + .map(|oldest_attempt_unix| { + oldest_attempt_unix + .saturating_add(self.window_secs) + .saturating_sub(now_unix) + .max(1) + }) + .unwrap_or(1), + ); + } + attempts.push_back(now_unix); + None + } } fn normalize_permissions( @@ -442,6 +588,34 @@ fn request_requires_auth(request: &RadrootsNostrConnectRequest) -> bool { ) } +fn build_rate_limiter( + window_secs: Option<u64>, + max_attempts: Option<usize>, +) -> Option<MycPolicyRateLimiter> { + match (window_secs, max_attempts) { + (Some(window_secs), Some(max_attempts)) => Some(MycPolicyRateLimiter { + window_secs, + max_attempts, + entries: Arc::new(Mutex::new(HashMap::new())), + }), + _ => None, + } +} + +fn prune_attempts(attempts: &mut VecDeque<u64>, now_unix: u64, window_secs: u64) { + while attempts + .front() + .copied() + .is_some_and(|attempt_unix| now_unix > attempt_unix.saturating_add(window_secs)) + { + let _ = attempts.pop_front(); + } +} + +fn throttled_reason(label: &str, retry_after_secs: u64) -> String { + format!("{label} throttled by policy; retry after {retry_after_secs}s") +} + fn now_unix_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -170,6 +170,20 @@ impl MycNip46Handler { } } } + if !matches!(connect_decision, crate::policy::MycConnectDecision::Deny) { + if let Some(reason) = self + .signer + .policy() + .connect_rate_limit_denied_reason(&client_public_key) + { + return Ok(MycNip46HandledRequest::respond( + RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }, + )); + } + } let evaluation = manager.evaluate_connect_request(client_public_key, request)?; match evaluation { @@ -1507,6 +1521,73 @@ mod tests { } #[test] + fn connect_requests_are_throttled_after_configured_limit() { + let runtime = runtime_with_config(MycConnectionApproval::NotRequired, |config| { + config.policy.connect_rate_limit_window_secs = Some(1); + config.policy.connect_rate_limit_max_attempts = Some(1); + }); + let handler = handler(&runtime); + + let first = handler + .handle_request_response( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect-1", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: Default::default(), + }, + ), + ) + .expect("first connect response"); + assert_eq!(first, RadrootsNostrConnectResponse::ConnectAcknowledged); + + let second = handler + .handle_request_response( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect-2", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: Default::default(), + }, + ), + ) + .expect("second connect response"); + assert!(matches!( + second, + RadrootsNostrConnectResponse::Error { error, .. } + if error.contains("connect attempts throttled by policy") + )); + + let connection = connection_for(&runtime, client_keys().public_key()); + runtime + .signer_manager() + .expect("manager") + .revoke_connection(&connection.connection_id, Some("test reset".to_owned())) + .expect("revoke connection"); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + let third = handler + .handle_request_response( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect-3", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: Default::default(), + }, + ), + ) + .expect("third connect response"); + assert_eq!(third, RadrootsNostrConnectResponse::ConnectAcknowledged); + } + + #[test] fn connect_preserves_pending_status_when_explicit_approval_is_required() { let runtime = runtime_with_explicit_approval(); let handler = handler(&runtime); @@ -1763,6 +1844,76 @@ mod tests { } #[test] + fn trusted_client_auth_challenge_reissue_is_throttled() { + let trusted_client_keys = client_keys_from_hex( + "5858585858585858585858585858585858585858585858585858585858585858", + ); + let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { + config.policy.trusted_client_pubkeys = vec![trusted_client_keys.public_key().to_hex()]; + config.policy.permission_ceiling = vec![sign_event_permission(1)].into(); + config.policy.allowed_sign_event_kinds = vec![1]; + config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); + config.policy.auth_pending_ttl_secs = 1; + config.policy.auth_challenge_rate_limit_window_secs = Some(60); + config.policy.auth_challenge_rate_limit_max_attempts = Some(1); + }); + let handler = handler(&runtime); + + let _ = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: vec![sign_event_permission(1)].into(), + }, + ), + ) + .expect("connect"); + + let first = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-sign-1", + RadrootsNostrConnectRequest::SignEvent(unsigned_event( + runtime.user_identity().public_key(), + 1, + "first", + )), + ), + ) + .expect("first sign request"); + assert_eq!( + first, + RadrootsNostrConnectResponse::AuthUrl("https://auth.example/challenge".to_owned()) + ); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + let second = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-sign-2", + RadrootsNostrConnectRequest::SignEvent(unsigned_event( + runtime.user_identity().public_key(), + 1, + "second", + )), + ), + ) + .expect("second sign request"); + assert!(matches!( + second, + RadrootsNostrConnectResponse::Error { error, .. } + if error.contains("auth challenge issuance throttled by policy") + )); + } + + #[test] fn base_methods_return_spec_results_after_connect() { let runtime = runtime(); let handler = handler(&runtime);