myc

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

policy.rs (35566B)


      1 use std::collections::{BTreeSet, HashMap, VecDeque};
      2 use std::sync::{Arc, Mutex};
      3 use std::time::{SystemTime, UNIX_EPOCH};
      4 
      5 use nostr::PublicKey;
      6 use radroots_nostr_connect::prelude::{
      7     RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
      8     RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage,
      9 };
     10 use radroots_nostr_signer::prelude::{
     11     RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend,
     12     RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerManager,
     13     RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Policy,
     14 };
     15 
     16 use crate::config::{MycConnectionApproval, MycPolicyConfig};
     17 use crate::error::MycError;
     18 
     19 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     20 pub enum MycConnectDecision {
     21     Allow,
     22     RequireApproval,
     23     Deny,
     24 }
     25 
     26 #[derive(Debug, Clone)]
     27 pub struct MycPolicyContext {
     28     default_connect_decision: MycConnectDecision,
     29     trusted_client_pubkeys: BTreeSet<String>,
     30     denied_client_pubkeys: BTreeSet<String>,
     31     permission_ceiling: RadrootsNostrConnectPermissions,
     32     allowed_sign_event_kinds: BTreeSet<u16>,
     33     auth_url: Option<String>,
     34     auth_pending_ttl_secs: u64,
     35     auth_authorized_ttl_secs: Option<u64>,
     36     reauth_after_inactivity_secs: Option<u64>,
     37     connect_rate_limiter: Option<MycPolicyRateLimiter>,
     38     auth_challenge_rate_limiter: Option<MycPolicyRateLimiter>,
     39 }
     40 
     41 #[derive(Debug, Clone)]
     42 struct MycPolicyRateLimiter {
     43     window_secs: u64,
     44     max_attempts: usize,
     45     entries: Arc<Mutex<HashMap<String, VecDeque<u64>>>>,
     46 }
     47 
     48 impl MycPolicyContext {
     49     pub fn from_config(config: &MycPolicyConfig) -> Result<Self, MycError> {
     50         Ok(Self {
     51             default_connect_decision: match config.connection_approval {
     52                 MycConnectionApproval::NotRequired => MycConnectDecision::Allow,
     53                 MycConnectionApproval::ExplicitUser => MycConnectDecision::RequireApproval,
     54                 MycConnectionApproval::Deny => MycConnectDecision::Deny,
     55             },
     56             trusted_client_pubkeys: normalize_public_key_set(&config.trusted_client_pubkeys)?,
     57             denied_client_pubkeys: normalize_public_key_set(&config.denied_client_pubkeys)?,
     58             permission_ceiling: normalize_permissions(config.permission_ceiling.clone()),
     59             allowed_sign_event_kinds: config.allowed_sign_event_kinds.iter().copied().collect(),
     60             auth_url: config.auth_url.clone(),
     61             auth_pending_ttl_secs: config.auth_pending_ttl_secs,
     62             auth_authorized_ttl_secs: config.auth_authorized_ttl_secs,
     63             reauth_after_inactivity_secs: config.reauth_after_inactivity_secs,
     64             connect_rate_limiter: build_rate_limiter(
     65                 config.connect_rate_limit_window_secs,
     66                 config.connect_rate_limit_max_attempts,
     67             ),
     68             auth_challenge_rate_limiter: build_rate_limiter(
     69                 config.auth_challenge_rate_limit_window_secs,
     70                 config.auth_challenge_rate_limit_max_attempts,
     71             ),
     72         })
     73     }
     74 
     75     pub fn default_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement {
     76         match self.default_connect_decision {
     77             MycConnectDecision::Allow => RadrootsNostrSignerApprovalRequirement::NotRequired,
     78             MycConnectDecision::RequireApproval | MycConnectDecision::Deny => {
     79                 RadrootsNostrSignerApprovalRequirement::ExplicitUser
     80             }
     81         }
     82     }
     83 
     84     pub fn connect_decision(&self, client_public_key: &PublicKey) -> MycConnectDecision {
     85         let client_public_key_hex = client_public_key.to_hex();
     86         if self.denied_client_pubkeys.contains(&client_public_key_hex) {
     87             return MycConnectDecision::Deny;
     88         }
     89         if self.trusted_client_pubkeys.contains(&client_public_key_hex) {
     90             return MycConnectDecision::Allow;
     91         }
     92         self.default_connect_decision
     93     }
     94 
     95     pub fn approval_requirement_for_client(
     96         &self,
     97         client_public_key: &PublicKey,
     98     ) -> Option<RadrootsNostrSignerApprovalRequirement> {
     99         match self.connect_decision(client_public_key) {
    100             MycConnectDecision::Allow => Some(RadrootsNostrSignerApprovalRequirement::NotRequired),
    101             MycConnectDecision::RequireApproval => {
    102                 Some(RadrootsNostrSignerApprovalRequirement::ExplicitUser)
    103             }
    104             MycConnectDecision::Deny => None,
    105         }
    106     }
    107 
    108     pub fn connect_rate_limit_denied_reason(
    109         &self,
    110         client_public_key: &PublicKey,
    111     ) -> Option<String> {
    112         self.connect_rate_limiter.as_ref().and_then(|limiter| {
    113             limiter
    114                 .check_and_record(&client_public_key.to_hex())
    115                 .map(|retry_after_secs| throttled_reason("connect attempts", retry_after_secs))
    116         })
    117     }
    118 
    119     pub fn auto_granted_permissions(
    120         &self,
    121         requested_permissions: &RadrootsNostrConnectPermissions,
    122     ) -> RadrootsNostrConnectPermissions {
    123         self.filtered_requested_permissions(requested_permissions)
    124     }
    125 
    126     pub fn filtered_requested_permissions(
    127         &self,
    128         requested_permissions: &RadrootsNostrConnectPermissions,
    129     ) -> RadrootsNostrConnectPermissions {
    130         let mut filtered = Vec::new();
    131 
    132         for permission in requested_permissions.as_slice() {
    133             if permission.method == RadrootsNostrConnectMethod::SignEvent
    134                 && permission.parameter.is_none()
    135                 && !self.allowed_sign_event_kinds.is_empty()
    136             {
    137                 for kind in &self.allowed_sign_event_kinds {
    138                     let candidate = RadrootsNostrConnectPermission::with_parameter(
    139                         RadrootsNostrConnectMethod::SignEvent,
    140                         format!("kind:{kind}"),
    141                     );
    142                     if self.permission_within_policy(&candidate) {
    143                         filtered.push(candidate);
    144                     }
    145                 }
    146                 continue;
    147             }
    148 
    149             if self.permission_within_policy(permission) {
    150                 filtered.push(permission.clone());
    151             }
    152         }
    153 
    154         normalize_permissions(filtered.into())
    155     }
    156 
    157     pub fn validate_operator_grants(
    158         &self,
    159         granted_permissions: RadrootsNostrConnectPermissions,
    160     ) -> Result<RadrootsNostrConnectPermissions, MycError> {
    161         let granted_permissions = normalize_permissions(granted_permissions);
    162         let invalid_permissions = granted_permissions
    163             .as_slice()
    164             .iter()
    165             .filter(|permission| !self.permission_within_policy(permission))
    166             .map(ToString::to_string)
    167             .collect::<Vec<_>>();
    168 
    169         if invalid_permissions.is_empty() {
    170             Ok(granted_permissions)
    171         } else {
    172             Err(MycError::InvalidOperation(format!(
    173                 "granted permissions exceed the configured policy ceiling: {}",
    174                 invalid_permissions.join(", ")
    175             )))
    176         }
    177     }
    178 
    179     pub fn prepare_request<B: RadrootsNostrSignerBackend>(
    180         &self,
    181         backend: &B,
    182         connection: &RadrootsNostrSignerConnectionRecord,
    183         request_message: &RadrootsNostrConnectRequestMessage,
    184     ) -> Result<Option<String>, MycError> {
    185         if self.client_is_denied(&connection.client_public_key) {
    186             return Ok(Some("client public key denied by policy".to_owned()));
    187         }
    188 
    189         if let Some(reason) = self.request_denied_reason(&request_message.request) {
    190             return Ok(Some(reason));
    191         }
    192 
    193         if connection.auth_state
    194             == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending
    195             && self.auth_challenge_is_expired(connection)
    196         {
    197             if self.request_uses_automatic_auth(connection, &request_message.request) {
    198                 if let Some(reason) =
    199                     self.require_auth_challenge_with_guardrails(backend, connection)?
    200                 {
    201                     return Ok(Some(reason));
    202                 }
    203             } else {
    204                 return Ok(Some(
    205                     "auth challenge expired; require a new auth challenge".to_owned(),
    206                 ));
    207             }
    208         } else if self.should_require_fresh_auth(connection, &request_message.request) {
    209             if let Some(reason) =
    210                 self.require_auth_challenge_with_guardrails(backend, connection)?
    211             {
    212                 return Ok(Some(reason));
    213             }
    214         }
    215 
    216         Ok(None)
    217     }
    218 
    219     pub fn ensure_authorize_auth_challenge_allowed(
    220         &self,
    221         connection: &RadrootsNostrSignerConnectionRecord,
    222     ) -> Result<(), MycError> {
    223         if connection.auth_state
    224             == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending
    225             && self.auth_challenge_is_expired(connection)
    226         {
    227             return Err(MycError::InvalidOperation(
    228                 "auth challenge expired; require a new auth challenge".to_owned(),
    229             ));
    230         }
    231         Ok(())
    232     }
    233 
    234     pub fn cleanup_stale_sessions(
    235         &self,
    236         manager: &RadrootsNostrSignerManager,
    237     ) -> Result<usize, MycError> {
    238         let mut cleaned = 0usize;
    239         for connection in manager.list_connections()? {
    240             if !self.stale_session_requires_cleanup(&connection) {
    241                 continue;
    242             }
    243             self.require_auth_challenge_with_manager(manager, &connection)?;
    244             cleaned += 1;
    245         }
    246         Ok(cleaned)
    247     }
    248 
    249     fn client_is_denied(&self, client_public_key: &PublicKey) -> bool {
    250         self.denied_client_pubkeys
    251             .contains(&client_public_key.to_hex())
    252     }
    253 
    254     fn client_is_trusted(&self, client_public_key: &PublicKey) -> bool {
    255         self.trusted_client_pubkeys
    256             .contains(&client_public_key.to_hex())
    257     }
    258 
    259     fn permission_within_policy(&self, permission: &RadrootsNostrConnectPermission) -> bool {
    260         if permission.method == RadrootsNostrConnectMethod::SignEvent
    261             && !self.allowed_sign_event_kinds.is_empty()
    262         {
    263             let Some(kind) = permission
    264                 .parameter
    265                 .as_deref()
    266                 .and_then(parse_sign_event_kind_parameter)
    267             else {
    268                 return false;
    269             };
    270             if !self.allowed_sign_event_kinds.contains(&kind) {
    271                 return false;
    272             }
    273         }
    274 
    275         if self.permission_ceiling.is_empty() {
    276             return true;
    277         }
    278 
    279         self.permission_ceiling
    280             .as_slice()
    281             .iter()
    282             .any(|ceiling| permission_within_ceiling(permission, ceiling))
    283     }
    284 
    285     fn request_denied_reason(&self, request: &RadrootsNostrConnectRequest) -> Option<String> {
    286         if self.permission_ceiling.is_empty()
    287             && (self.allowed_sign_event_kinds.is_empty()
    288                 || !matches!(request, RadrootsNostrConnectRequest::SignEvent(_)))
    289         {
    290             return None;
    291         }
    292 
    293         let required_permission = required_permission_for_request(request)?;
    294         if self.permission_within_policy(&required_permission) {
    295             None
    296         } else {
    297             Some(format!(
    298                 "request {} is outside the configured policy ceiling",
    299                 request.method()
    300             ))
    301         }
    302     }
    303 
    304     fn request_uses_automatic_auth(
    305         &self,
    306         connection: &RadrootsNostrSignerConnectionRecord,
    307         request: &RadrootsNostrConnectRequest,
    308     ) -> bool {
    309         self.automatic_auth_enabled_for_connection(connection) && request_requires_auth(request)
    310     }
    311 
    312     fn should_require_fresh_auth(
    313         &self,
    314         connection: &RadrootsNostrSignerConnectionRecord,
    315         request: &RadrootsNostrConnectRequest,
    316     ) -> bool {
    317         if !self.request_uses_automatic_auth(connection, request) {
    318             return false;
    319         }
    320 
    321         if connection.auth_state
    322             == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending
    323         {
    324             return false;
    325         }
    326 
    327         let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else {
    328             return true;
    329         };
    330         let now_unix = now_unix_secs();
    331 
    332         if self
    333             .auth_authorized_ttl_secs
    334             .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl))
    335         {
    336             return true;
    337         }
    338 
    339         self.reauth_after_inactivity_secs.is_some_and(|ttl| {
    340             let Some(last_request_at_unix) = connection.last_request_at_unix else {
    341                 return false;
    342             };
    343             now_unix > last_request_at_unix.saturating_add(ttl)
    344         })
    345     }
    346 
    347     fn auth_challenge_is_expired(&self, connection: &RadrootsNostrSignerConnectionRecord) -> bool {
    348         let Some(auth_challenge) = connection.auth_challenge.as_ref() else {
    349             return false;
    350         };
    351         now_unix_secs()
    352             > auth_challenge
    353                 .required_at_unix
    354                 .saturating_add(self.auth_pending_ttl_secs)
    355     }
    356 
    357     fn auth_url(&self) -> Result<&str, MycError> {
    358         self.auth_url.as_deref().ok_or_else(|| {
    359             MycError::InvalidOperation(
    360                 "automatic auth policy requires policy.auth_url to be configured".to_owned(),
    361             )
    362         })
    363     }
    364 
    365     fn automatic_auth_enabled_for_connection(
    366         &self,
    367         connection: &RadrootsNostrSignerConnectionRecord,
    368     ) -> bool {
    369         self.auth_url.is_some() && self.client_is_trusted(&connection.client_public_key)
    370     }
    371 
    372     fn require_auth_challenge_with_guardrails<B: RadrootsNostrSignerBackend>(
    373         &self,
    374         backend: &B,
    375         connection: &RadrootsNostrSignerConnectionRecord,
    376     ) -> Result<Option<String>, MycError> {
    377         if let Some(retry_after_secs) = self
    378             .auth_challenge_rate_limiter
    379             .as_ref()
    380             .and_then(|limiter| limiter.check_and_record(&connection.client_public_key.to_hex()))
    381         {
    382             return Ok(Some(throttled_reason(
    383                 "auth challenge issuance",
    384                 retry_after_secs,
    385             )));
    386         }
    387         self.require_auth_challenge_with_backend(backend, connection)?;
    388         Ok(None)
    389     }
    390 
    391     fn require_auth_challenge_with_backend<B: RadrootsNostrSignerBackend>(
    392         &self,
    393         backend: &B,
    394         connection: &RadrootsNostrSignerConnectionRecord,
    395     ) -> Result<(), MycError> {
    396         backend.require_auth_challenge(&connection.connection_id, self.auth_url()?)?;
    397         Ok(())
    398     }
    399 
    400     fn require_auth_challenge_with_manager(
    401         &self,
    402         manager: &RadrootsNostrSignerManager,
    403         connection: &RadrootsNostrSignerConnectionRecord,
    404     ) -> Result<(), MycError> {
    405         manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?;
    406         Ok(())
    407     }
    408 
    409     fn stale_session_requires_cleanup(
    410         &self,
    411         connection: &RadrootsNostrSignerConnectionRecord,
    412     ) -> bool {
    413         if connection.is_terminal()
    414             || connection.auth_state
    415                 != radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Authorized
    416             || !self.automatic_auth_enabled_for_connection(connection)
    417         {
    418             return false;
    419         }
    420 
    421         let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else {
    422             return true;
    423         };
    424         let now_unix = now_unix_secs();
    425 
    426         if self
    427             .auth_authorized_ttl_secs
    428             .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl))
    429         {
    430             return true;
    431         }
    432 
    433         self.reauth_after_inactivity_secs.is_some_and(|ttl| {
    434             connection
    435                 .last_request_at_unix
    436                 .is_some_and(|last_request_at_unix| {
    437                     now_unix > last_request_at_unix.saturating_add(ttl)
    438                 })
    439         })
    440     }
    441 }
    442 
    443 impl<B: RadrootsNostrSignerBackend> RadrootsNostrSignerNip46Policy<B> for MycPolicyContext {
    444     fn connect_decision(
    445         &self,
    446         client_public_key: &PublicKey,
    447     ) -> RadrootsNostrSignerNip46ConnectDecision {
    448         match self.connect_decision(client_public_key) {
    449             MycConnectDecision::Allow => RadrootsNostrSignerNip46ConnectDecision::Allow,
    450             MycConnectDecision::RequireApproval => {
    451                 RadrootsNostrSignerNip46ConnectDecision::RequireApproval
    452             }
    453             MycConnectDecision::Deny => RadrootsNostrSignerNip46ConnectDecision::Deny,
    454         }
    455     }
    456 
    457     fn connect_rate_limit_denied_reason(&self, client_public_key: &PublicKey) -> Option<String> {
    458         self.connect_rate_limit_denied_reason(client_public_key)
    459     }
    460 
    461     fn approval_requirement_for_client(
    462         &self,
    463         client_public_key: &PublicKey,
    464     ) -> Option<RadrootsNostrSignerApprovalRequirement> {
    465         self.approval_requirement_for_client(client_public_key)
    466     }
    467 
    468     fn filtered_requested_permissions(
    469         &self,
    470         requested_permissions: &RadrootsNostrConnectPermissions,
    471     ) -> RadrootsNostrConnectPermissions {
    472         self.filtered_requested_permissions(requested_permissions)
    473     }
    474 
    475     fn auto_granted_permissions(
    476         &self,
    477         requested_permissions: &RadrootsNostrConnectPermissions,
    478     ) -> RadrootsNostrConnectPermissions {
    479         self.auto_granted_permissions(requested_permissions)
    480     }
    481 
    482     fn prepare_request(
    483         &self,
    484         backend: &B,
    485         connection: &RadrootsNostrSignerConnectionRecord,
    486         request_message: &RadrootsNostrConnectRequestMessage,
    487     ) -> Result<Option<String>, radroots_nostr_signer::prelude::RadrootsNostrSignerError> {
    488         self.prepare_request(backend, connection, request_message)
    489             .map_err(myc_policy_signer_error)
    490     }
    491 }
    492 
    493 impl MycPolicyRateLimiter {
    494     fn check_and_record(&self, key: &str) -> Option<u64> {
    495         let now_unix = now_unix_secs();
    496         let mut guard = self
    497             .entries
    498             .lock()
    499             .unwrap_or_else(|poisoned| poisoned.into_inner());
    500         let attempts = guard.entry(key.to_owned()).or_default();
    501         prune_attempts(attempts, now_unix, self.window_secs);
    502         if attempts.len() >= self.max_attempts {
    503             return Some(
    504                 attempts
    505                     .front()
    506                     .copied()
    507                     .map(|oldest_attempt_unix| {
    508                         oldest_attempt_unix
    509                             .saturating_add(self.window_secs)
    510                             .saturating_sub(now_unix)
    511                             .max(1)
    512                     })
    513                     .unwrap_or(1),
    514             );
    515         }
    516         attempts.push_back(now_unix);
    517         None
    518     }
    519 }
    520 
    521 fn normalize_permissions(
    522     permissions: RadrootsNostrConnectPermissions,
    523 ) -> RadrootsNostrConnectPermissions {
    524     let mut permissions = permissions.into_vec();
    525     permissions.sort();
    526     permissions.dedup();
    527     permissions.into()
    528 }
    529 
    530 fn normalize_public_key_set(values: &[String]) -> Result<BTreeSet<String>, MycError> {
    531     values
    532         .iter()
    533         .map(|value| normalize_public_key_hex(value))
    534         .collect()
    535 }
    536 
    537 fn normalize_public_key_hex(value: &str) -> Result<String, MycError> {
    538     let trimmed = value.trim();
    539     if trimmed.is_empty() {
    540         return Err(MycError::InvalidConfig(
    541             "policy client pubkeys must not contain empty values".to_owned(),
    542         ));
    543     }
    544     let public_key = PublicKey::parse(trimmed)
    545         .or_else(|_| PublicKey::from_hex(trimmed))
    546         .map_err(|_| {
    547             MycError::InvalidConfig(format!(
    548                 "policy client pubkey `{trimmed}` is not a valid nostr public key"
    549             ))
    550         })?;
    551     Ok(public_key.to_hex())
    552 }
    553 
    554 fn required_permission_for_request(
    555     request: &RadrootsNostrConnectRequest,
    556 ) -> Option<RadrootsNostrConnectPermission> {
    557     match request {
    558         RadrootsNostrConnectRequest::Connect { .. }
    559         | RadrootsNostrConnectRequest::GetPublicKey
    560         | RadrootsNostrConnectRequest::GetSessionCapability
    561         | RadrootsNostrConnectRequest::Ping => None,
    562         RadrootsNostrConnectRequest::SignEvent(unsigned_event) => {
    563             Some(RadrootsNostrConnectPermission::with_parameter(
    564                 RadrootsNostrConnectMethod::SignEvent,
    565                 format!("kind:{}", unsigned_event.kind.as_u16()),
    566             ))
    567         }
    568         RadrootsNostrConnectRequest::Nip04Encrypt { .. } => Some(
    569             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt),
    570         ),
    571         RadrootsNostrConnectRequest::Nip04Decrypt { .. } => Some(
    572             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Decrypt),
    573         ),
    574         RadrootsNostrConnectRequest::Nip44Encrypt { .. } => Some(
    575             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt),
    576         ),
    577         RadrootsNostrConnectRequest::Nip44Decrypt { .. } => Some(
    578             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Decrypt),
    579         ),
    580         RadrootsNostrConnectRequest::SwitchRelays => Some(RadrootsNostrConnectPermission::new(
    581             RadrootsNostrConnectMethod::SwitchRelays,
    582         )),
    583         RadrootsNostrConnectRequest::Custom { method, .. } => {
    584             Some(RadrootsNostrConnectPermission::new(method.clone()))
    585         }
    586     }
    587 }
    588 
    589 fn permission_within_ceiling(
    590     permission: &RadrootsNostrConnectPermission,
    591     ceiling: &RadrootsNostrConnectPermission,
    592 ) -> bool {
    593     if permission.method != ceiling.method {
    594         return false;
    595     }
    596 
    597     match (
    598         &permission.method,
    599         permission.parameter.as_deref(),
    600         ceiling.parameter.as_deref(),
    601     ) {
    602         (RadrootsNostrConnectMethod::SignEvent, _, None) => true,
    603         (RadrootsNostrConnectMethod::SignEvent, Some(parameter), Some(ceiling_parameter)) => {
    604             sign_event_parameter_eq(parameter, ceiling_parameter)
    605         }
    606         (RadrootsNostrConnectMethod::SignEvent, None, Some(_)) => false,
    607         (_, _, None) => true,
    608         (_, Some(parameter), Some(ceiling_parameter)) => parameter == ceiling_parameter,
    609         (_, None, Some(_)) => false,
    610     }
    611 }
    612 
    613 fn sign_event_parameter_eq(left: &str, right: &str) -> bool {
    614     parse_sign_event_kind_parameter(left) == parse_sign_event_kind_parameter(right)
    615 }
    616 
    617 fn parse_sign_event_kind_parameter(value: &str) -> Option<u16> {
    618     value
    619         .strip_prefix("kind:")
    620         .unwrap_or(value)
    621         .parse::<u16>()
    622         .ok()
    623 }
    624 
    625 fn request_requires_auth(request: &RadrootsNostrConnectRequest) -> bool {
    626     !matches!(
    627         request,
    628         RadrootsNostrConnectRequest::Connect { .. }
    629             | RadrootsNostrConnectRequest::GetPublicKey
    630             | RadrootsNostrConnectRequest::GetSessionCapability
    631             | RadrootsNostrConnectRequest::Ping
    632     )
    633 }
    634 
    635 fn build_rate_limiter(
    636     window_secs: Option<u64>,
    637     max_attempts: Option<usize>,
    638 ) -> Option<MycPolicyRateLimiter> {
    639     match (window_secs, max_attempts) {
    640         (Some(window_secs), Some(max_attempts)) => Some(MycPolicyRateLimiter {
    641             window_secs,
    642             max_attempts,
    643             entries: Arc::new(Mutex::new(HashMap::new())),
    644         }),
    645         _ => None,
    646     }
    647 }
    648 
    649 fn prune_attempts(attempts: &mut VecDeque<u64>, now_unix: u64, window_secs: u64) {
    650     while attempts
    651         .front()
    652         .copied()
    653         .is_some_and(|attempt_unix| now_unix > attempt_unix.saturating_add(window_secs))
    654     {
    655         let _ = attempts.pop_front();
    656     }
    657 }
    658 
    659 fn throttled_reason(label: &str, retry_after_secs: u64) -> String {
    660     format!("{label} throttled by policy; retry after {retry_after_secs}s")
    661 }
    662 
    663 fn now_unix_secs() -> u64 {
    664     SystemTime::now()
    665         .duration_since(UNIX_EPOCH)
    666         .map(|duration| duration.as_secs())
    667         .unwrap_or_default()
    668 }
    669 
    670 fn myc_policy_signer_error(
    671     error: MycError,
    672 ) -> radroots_nostr_signer::prelude::RadrootsNostrSignerError {
    673     radroots_nostr_signer::prelude::RadrootsNostrSignerError::InvalidState(error.to_string())
    674 }
    675 
    676 #[cfg(test)]
    677 mod tests {
    678     use super::{MycConnectDecision, MycPolicyContext};
    679     use crate::config::{MycConnectionApproval, MycPolicyConfig};
    680     use nostr::PublicKey;
    681     use radroots_identity::RadrootsIdentity;
    682     use radroots_nostr_connect::prelude::{
    683         RadrootsNostrConnectMethod, RadrootsNostrConnectPermission,
    684         RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
    685         RadrootsNostrConnectRequestMessage,
    686     };
    687     use radroots_nostr_signer::prelude::{
    688         RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerApprovalRequirement,
    689         RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
    690         RadrootsNostrSignerManager,
    691     };
    692     use serde_json::json;
    693     use std::thread;
    694     use std::time::Duration;
    695 
    696     fn public_key(hex: &str) -> PublicKey {
    697         PublicKey::parse(hex).expect("public key")
    698     }
    699 
    700     fn identity(secret_key: &str) -> RadrootsIdentity {
    701         RadrootsIdentity::from_secret_key_str(secret_key).expect("identity")
    702     }
    703 
    704     fn in_memory_manager() -> RadrootsNostrSignerManager {
    705         let manager = RadrootsNostrSignerManager::new_in_memory();
    706         manager
    707             .set_signer_identity(
    708                 identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
    709                     .to_public(),
    710             )
    711             .expect("set signer identity");
    712         manager
    713     }
    714 
    715     fn backend_for(manager: &RadrootsNostrSignerManager) -> RadrootsNostrEmbeddedSignerBackend {
    716         RadrootsNostrEmbeddedSignerBackend::new(
    717             manager.clone(),
    718             identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
    719         )
    720         .expect("backend")
    721     }
    722 
    723     fn register_connection(
    724         manager: &RadrootsNostrSignerManager,
    725         client_public_key: PublicKey,
    726     ) -> radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionRecord {
    727         manager
    728             .register_connection(
    729                 RadrootsNostrSignerConnectionDraft::new(
    730                     client_public_key,
    731                     identity("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
    732                         .to_public(),
    733                 )
    734                 .with_requested_permissions(
    735                     vec![RadrootsNostrConnectPermission::with_parameter(
    736                         RadrootsNostrConnectMethod::SignEvent,
    737                         "kind:1",
    738                     )]
    739                     .into(),
    740                 )
    741                 .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired),
    742             )
    743             .expect("register connection")
    744     }
    745 
    746     fn unsigned_event(kind: u16) -> nostr::UnsignedEvent {
    747         serde_json::from_value(json!({
    748             "pubkey": public_key("1111111111111111111111111111111111111111111111111111111111111111").to_hex(),
    749             "created_at": 1,
    750             "kind": kind,
    751             "tags": [],
    752             "content": "hello"
    753         }))
    754         .expect("unsigned event")
    755     }
    756 
    757     #[test]
    758     fn connect_decision_prefers_deny_then_trust_then_default() {
    759         let mut config = MycPolicyConfig::default();
    760         config.connection_approval = MycConnectionApproval::ExplicitUser;
    761         config.trusted_client_pubkeys =
    762             vec!["2222222222222222222222222222222222222222222222222222222222222222".to_owned()];
    763         config.denied_client_pubkeys =
    764             vec!["3333333333333333333333333333333333333333333333333333333333333333".to_owned()];
    765         let policy = MycPolicyContext::from_config(&config).expect("policy");
    766 
    767         assert_eq!(
    768             policy.connect_decision(&public_key(
    769                 "2222222222222222222222222222222222222222222222222222222222222222"
    770             )),
    771             MycConnectDecision::Allow
    772         );
    773         assert_eq!(
    774             policy.connect_decision(&public_key(
    775                 "3333333333333333333333333333333333333333333333333333333333333333"
    776             )),
    777             MycConnectDecision::Deny
    778         );
    779         assert_eq!(
    780             policy.connect_decision(&public_key(
    781                 "4444444444444444444444444444444444444444444444444444444444444444"
    782             )),
    783             MycConnectDecision::RequireApproval
    784         );
    785     }
    786 
    787     #[test]
    788     fn auto_granted_permissions_apply_policy_ceiling_and_kind_limits() {
    789         let mut config = MycPolicyConfig::default();
    790         config.permission_ceiling = vec![
    791             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt),
    792             RadrootsNostrConnectPermission::with_parameter(
    793                 RadrootsNostrConnectMethod::SignEvent,
    794                 "kind:1",
    795             ),
    796         ]
    797         .into();
    798         config.allowed_sign_event_kinds = vec![1];
    799         let policy = MycPolicyContext::from_config(&config).expect("policy");
    800 
    801         let requested_permissions: RadrootsNostrConnectPermissions = vec![
    802             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt),
    803             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SignEvent),
    804             RadrootsNostrConnectPermission::with_parameter(
    805                 RadrootsNostrConnectMethod::SignEvent,
    806                 "kind:2",
    807             ),
    808         ]
    809         .into();
    810         let filtered = policy.auto_granted_permissions(&requested_permissions);
    811 
    812         assert_eq!(filtered.to_string(), "sign_event:kind:1,nip04_encrypt");
    813     }
    814 
    815     #[test]
    816     fn request_denied_reason_applies_sign_event_kind_limits() {
    817         let mut config = MycPolicyConfig::default();
    818         config.allowed_sign_event_kinds = vec![1];
    819         let policy = MycPolicyContext::from_config(&config).expect("policy");
    820         let manager = in_memory_manager();
    821         let backend = backend_for(&manager);
    822         let connection = register_connection(
    823             &manager,
    824             public_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
    825         );
    826 
    827         let denied = policy
    828             .prepare_request(
    829                 &backend,
    830                 &connection,
    831                 &RadrootsNostrConnectRequestMessage::new(
    832                     "request-1",
    833                     RadrootsNostrConnectRequest::SignEvent(unsigned_event(2)),
    834                 ),
    835             )
    836             .expect("prepare request");
    837 
    838         assert_eq!(
    839             denied,
    840             Some("request sign_event is outside the configured policy ceiling".to_owned())
    841         );
    842     }
    843 
    844     #[test]
    845     fn validate_operator_grants_rejects_out_of_policy_permissions() {
    846         let mut config = MycPolicyConfig::default();
    847         config.permission_ceiling =
    848             RadrootsNostrConnectPermissions::from(vec![RadrootsNostrConnectPermission::new(
    849                 RadrootsNostrConnectMethod::Nip04Encrypt,
    850             )]);
    851         let policy = MycPolicyContext::from_config(&config).expect("policy");
    852 
    853         let error = policy
    854             .validate_operator_grants(
    855                 vec![RadrootsNostrConnectPermission::new(
    856                     RadrootsNostrConnectMethod::Nip44Encrypt,
    857                 )]
    858                 .into(),
    859             )
    860             .expect_err("grant outside ceiling");
    861         assert!(
    862             error
    863                 .to_string()
    864                 .contains("granted permissions exceed the configured policy ceiling")
    865         );
    866     }
    867 
    868     #[test]
    869     fn prepare_request_requires_fresh_auth_after_authorized_ttl() {
    870         let client_public_key =
    871             public_key("2222222222222222222222222222222222222222222222222222222222222222");
    872         let mut config = MycPolicyConfig::default();
    873         config.trusted_client_pubkeys = vec![client_public_key.to_hex()];
    874         config.auth_url = Some("https://auth.example".to_owned());
    875         config.auth_authorized_ttl_secs = Some(1);
    876         let policy = MycPolicyContext::from_config(&config).expect("policy");
    877         let manager = in_memory_manager();
    878         let backend = backend_for(&manager);
    879         let connection = register_connection(&manager, client_public_key);
    880 
    881         manager
    882             .require_auth_challenge(&connection.connection_id, "https://auth.example")
    883             .expect("require auth challenge");
    884         manager
    885             .authorize_auth_challenge(&connection.connection_id)
    886             .expect("authorize auth challenge");
    887         thread::sleep(Duration::from_secs(2));
    888 
    889         let connection = manager
    890             .get_connection(&connection.connection_id)
    891             .expect("connection lookup")
    892             .expect("connection");
    893         let denied = policy
    894             .prepare_request(
    895                 &backend,
    896                 &connection,
    897                 &RadrootsNostrConnectRequestMessage::new(
    898                     "request-1",
    899                     RadrootsNostrConnectRequest::SignEvent(unsigned_event(1)),
    900                 ),
    901             )
    902             .expect("prepare request");
    903 
    904         assert_eq!(denied, None);
    905         let updated_connection = manager
    906             .get_connection(&connection.connection_id)
    907             .expect("connection lookup")
    908             .expect("connection");
    909         assert_eq!(
    910             updated_connection.auth_state,
    911             RadrootsNostrSignerAuthState::Pending
    912         );
    913         assert_eq!(
    914             updated_connection
    915                 .auth_challenge
    916                 .expect("auth challenge")
    917                 .auth_url,
    918             "https://auth.example/"
    919         );
    920     }
    921 
    922     #[test]
    923     fn prepare_request_requires_fresh_auth_after_inactivity() {
    924         let client_public_key =
    925             public_key("2323232323232323232323232323232323232323232323232323232323232323");
    926         let mut config = MycPolicyConfig::default();
    927         config.trusted_client_pubkeys = vec![client_public_key.to_hex()];
    928         config.auth_url = Some("https://auth.example".to_owned());
    929         config.reauth_after_inactivity_secs = Some(1);
    930         let policy = MycPolicyContext::from_config(&config).expect("policy");
    931         let manager = in_memory_manager();
    932         let backend = backend_for(&manager);
    933         let connection = register_connection(&manager, client_public_key);
    934 
    935         manager
    936             .require_auth_challenge(&connection.connection_id, "https://auth.example")
    937             .expect("require auth challenge");
    938         manager
    939             .authorize_auth_challenge(&connection.connection_id)
    940             .expect("authorize auth challenge");
    941         manager
    942             .record_request(
    943                 &connection.connection_id,
    944                 "request-0",
    945                 RadrootsNostrConnectMethod::SignEvent,
    946                 radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Allowed,
    947                 None,
    948             )
    949             .expect("record request");
    950         thread::sleep(Duration::from_secs(2));
    951 
    952         let connection = manager
    953             .get_connection(&connection.connection_id)
    954             .expect("connection lookup")
    955             .expect("connection");
    956         let denied = policy
    957             .prepare_request(
    958                 &backend,
    959                 &connection,
    960                 &RadrootsNostrConnectRequestMessage::new(
    961                     "request-1",
    962                     RadrootsNostrConnectRequest::SignEvent(unsigned_event(1)),
    963                 ),
    964             )
    965             .expect("prepare request");
    966 
    967         assert_eq!(denied, None);
    968         let updated_connection = manager
    969             .get_connection(&connection.connection_id)
    970             .expect("connection lookup")
    971             .expect("connection");
    972         assert_eq!(
    973             updated_connection.auth_state,
    974             RadrootsNostrSignerAuthState::Pending
    975         );
    976     }
    977 }