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 | ++++++ |
| M | src/app/runtime.rs | | | 70 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | src/config.rs | | | 121 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/policy.rs | | | 186 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | src/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);