myc

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

nip46_authorization.rs (8120B)


      1 //! Configuration-bound NIP-46 connection and challenge policy.
      2 
      3 use serde_json::Value;
      4 
      5 use crate::{
      6     MycAuthorizationChallengeRecord, MycAuthorizationChallengeRequest,
      7     MycAuthorizationChallengeState, MycAuthorizationChallengeUrl, MycConnectionAdmissionPolicy,
      8     MycConnectionAdmissionRequest, MycConnectionOperatorDecision, MycConnectionPermission,
      9     MycConnectionPermissionSet, MycConnectionTimeUnixMs, MycNip46ClientPublicKey,
     10 };
     11 
     12 #[derive(Clone, PartialEq, Eq)]
     13 pub(crate) struct MycConnectionPolicies {
     14     trusted_clients: Box<[MycNip46ClientPublicKey]>,
     15     denied_clients: Box<[MycNip46ClientPublicKey]>,
     16     permission_ceiling: MycConnectionPermissionSet,
     17     challenge: Option<MycChallengePolicy>,
     18     retention: MycAuthorizationRetentionPolicy,
     19 }
     20 
     21 #[derive(Clone, PartialEq, Eq)]
     22 struct MycChallengePolicy {
     23     url: MycAuthorizationChallengeUrl,
     24     pending_lifetime_ms: u64,
     25     authorized_lifetime_ms: u64,
     26 }
     27 
     28 #[derive(Clone, Copy, PartialEq, Eq)]
     29 struct MycAuthorizationRetentionPolicy {
     30     terminal_connections_ms: u64,
     31     terminal_challenges_ms: u64,
     32     request_dedup_ms: u64,
     33     completed_outbox_ms: u64,
     34 }
     35 
     36 impl MycConnectionPolicies {
     37     pub(crate) fn from_normalized(document: &Value) -> Result<Self, ()> {
     38         let invalid = || ();
     39         let strings = |pointer: &str| {
     40             document
     41                 .pointer(pointer)
     42                 .and_then(Value::as_array)
     43                 .ok_or_else(invalid)?
     44                 .iter()
     45                 .map(|value| value.as_str().ok_or_else(invalid))
     46                 .collect::<Result<Vec<_>, _>>()
     47         };
     48         let clients = |pointer: &str| {
     49             strings(pointer)?
     50                 .into_iter()
     51                 .map(|value| MycNip46ClientPublicKey::new(value).map_err(|_| invalid()))
     52                 .collect::<Result<Vec<_>, _>>()
     53                 .map(Vec::into_boxed_slice)
     54         };
     55         let trusted_clients = clients("/policy/trusted_clients")?;
     56         let denied_clients = clients("/policy/denied_clients")?;
     57         if trusted_clients
     58             .iter()
     59             .any(|trusted| denied_clients.contains(trusted))
     60         {
     61             return Err(());
     62         }
     63         let permission_ceiling = MycConnectionPermissionSet::new(
     64             &strings("/policy/permission_ceiling")?
     65                 .into_iter()
     66                 .map(|value| MycConnectionPermission::parse(value).ok_or_else(invalid))
     67                 .collect::<Result<Vec<_>, _>>()?,
     68         )
     69         .map_err(|_| ())?;
     70         let integer = |pointer: &str| {
     71             document
     72                 .pointer(pointer)
     73                 .and_then(Value::as_u64)
     74                 .filter(|value| *value > 0 && i64::try_from(*value).is_ok())
     75                 .ok_or_else(invalid)
     76         };
     77         let challenge = document
     78             .pointer("/policy/challenges/enabled")
     79             .and_then(Value::as_bool)
     80             .ok_or_else(invalid)?
     81             .then(|| {
     82                 let url = document
     83                     .pointer("/policy/challenges/url")
     84                     .and_then(Value::as_str)
     85                     .ok_or_else(invalid)
     86                     .and_then(|value| MycAuthorizationChallengeUrl::new(value).map_err(|_| ()))?;
     87                 let pending_lifetime_ms = integer("/policy/challenges/pending_lifetime_ms")?;
     88                 let authorized_lifetime_ms = integer("/policy/challenges/authorized_lifetime_ms")?;
     89                 if authorized_lifetime_ms < pending_lifetime_ms {
     90                     return Err(());
     91                 }
     92                 Ok(MycChallengePolicy {
     93                     url,
     94                     pending_lifetime_ms,
     95                     authorized_lifetime_ms,
     96                 })
     97             })
     98             .transpose()?;
     99         let retention = MycAuthorizationRetentionPolicy {
    100             terminal_connections_ms: integer("/policy/retention/terminal_connections_ms")?,
    101             terminal_challenges_ms: integer("/policy/retention/terminal_challenges_ms")?,
    102             request_dedup_ms: integer("/policy/retention/request_dedup_ms")?,
    103             completed_outbox_ms: integer("/policy/retention/completed_outbox_ms")?,
    104         };
    105         Ok(Self {
    106             trusted_clients,
    107             denied_clients,
    108             permission_ceiling,
    109             challenge,
    110             retention,
    111         })
    112     }
    113 
    114     fn expected_admission(&self, client: &MycNip46ClientPublicKey) -> MycConnectionAdmissionPolicy {
    115         if self.denied_clients.contains(client) {
    116             MycConnectionAdmissionPolicy::Denied
    117         } else if self.trusted_clients.contains(client) {
    118             MycConnectionAdmissionPolicy::Trusted
    119         } else {
    120             MycConnectionAdmissionPolicy::ExplicitApproval
    121         }
    122     }
    123 
    124     pub(crate) fn admits_connection_request(
    125         &self,
    126         request: &MycConnectionAdmissionRequest,
    127     ) -> bool {
    128         let expected = self.expected_admission(request.client_public_key());
    129         if request.policy() != expected {
    130             return false;
    131         }
    132         if expected == MycConnectionAdmissionPolicy::Denied {
    133             return true;
    134         }
    135         if !request
    136             .requested_permissions()
    137             .is_subset_of(&self.permission_ceiling)
    138         {
    139             return false;
    140         }
    141         match expected {
    142             MycConnectionAdmissionPolicy::Trusted => {
    143                 self.admits_authorized_until(request.observed_at(), request.authorized_until())
    144             }
    145             MycConnectionAdmissionPolicy::ExplicitApproval => request.authorized_until().is_none(),
    146             MycConnectionAdmissionPolicy::Denied => true,
    147         }
    148     }
    149 
    150     pub(crate) fn admits_operator_decision(
    151         &self,
    152         observed_at: MycConnectionTimeUnixMs,
    153         decision: &MycConnectionOperatorDecision,
    154     ) -> bool {
    155         match decision {
    156             MycConnectionOperatorDecision::Approve {
    157                 granted_permissions,
    158                 authorized_until,
    159             } => {
    160                 granted_permissions.is_subset_of(&self.permission_ceiling)
    161                     && self.admits_authorized_until(observed_at, *authorized_until)
    162             }
    163             MycConnectionOperatorDecision::Deny => true,
    164         }
    165     }
    166 
    167     pub(crate) fn admits_challenge_request(
    168         &self,
    169         request: &MycAuthorizationChallengeRequest,
    170     ) -> bool {
    171         let Some(policy) = &self.challenge else {
    172             return false;
    173         };
    174         request.url() == &policy.url
    175             && request
    176                 .expires_at()
    177                 .get()
    178                 .checked_sub(request.issued_at().get())
    179                 .is_some_and(|lifetime| lifetime <= policy.pending_lifetime_ms)
    180     }
    181 
    182     pub(crate) fn challenge_is_current(
    183         &self,
    184         record: &MycAuthorizationChallengeRecord,
    185         observed_at: MycConnectionTimeUnixMs,
    186     ) -> bool {
    187         let Some(policy) = &self.challenge else {
    188             return false;
    189         };
    190         record.state() == MycAuthorizationChallengeState::Authorized
    191             && record.resolved_at().is_some_and(|resolved| {
    192                 observed_at >= resolved
    193                     && observed_at
    194                         .get()
    195                         .checked_sub(resolved.get())
    196                         .is_some_and(|age| age <= policy.authorized_lifetime_ms)
    197             })
    198     }
    199 
    200     fn admits_authorized_until(
    201         &self,
    202         observed_at: MycConnectionTimeUnixMs,
    203         authorized_until: Option<MycConnectionTimeUnixMs>,
    204     ) -> bool {
    205         match (&self.challenge, authorized_until) {
    206             (None, None) => true,
    207             (Some(policy), Some(until)) => until
    208                 .get()
    209                 .checked_sub(observed_at.get())
    210                 .is_some_and(|lifetime| lifetime > 0 && lifetime <= policy.authorized_lifetime_ms),
    211             _ => false,
    212         }
    213     }
    214 
    215     pub(crate) const fn retention_is_bounded(&self) -> bool {
    216         self.retention.terminal_connections_ms > 0
    217             && self.retention.terminal_challenges_ms > 0
    218             && self.retention.request_dedup_ms > 0
    219             && self.retention.completed_outbox_ms > 0
    220     }
    221 }