use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::fmt; use std::hash::Hash; use std::marker::PhantomData; use std::ops::Deref; use std::str::FromStr; macro_rules! impl_string_common { ($name:ident) => { impl $name { pub fn as_str(&self) -> &str { &self.0 } pub fn into_inner(self) -> String { self.0 } } impl AsRef for $name { fn as_ref(&self) -> &str { &self.0 } } impl std::borrow::Borrow for $name { fn borrow(&self) -> &str { &self.0 } } impl Deref for $name { type Target = str; fn deref(&self) -> &Self::Target { &self.0 } } impl From<$name> for String { fn from(val: $name) -> Self { val.0 } } impl<'a> From<&'a $name> for Cow<'a, str> { fn from(val: &'a $name) -> Self { Cow::Borrowed(&val.0) } } impl PartialEq for $name { fn eq(&self, other: &str) -> bool { self.0 == other } } impl PartialEq<&str> for $name { fn eq(&self, other: &&str) -> bool { self.0 == *other } } impl PartialEq for $name { fn eq(&self, other: &String) -> bool { self.0 == *other } } impl PartialEq<$name> for String { fn eq(&self, other: &$name) -> bool { *self == other.0 } } impl PartialEq<$name> for &str { fn eq(&self, other: &$name) -> bool { *self == other.0 } } impl fmt::Display for $name { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } }; } macro_rules! simple_string_newtype { ( $(#[$meta:meta])* $vis:vis struct $name:ident; ) => { $(#[$meta])* #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type)] #[serde(transparent)] #[sqlx(transparent)] $vis struct $name(String); impl $name { pub fn new(s: impl Into) -> Self { Self(s.into()) } } impl From for $name { fn from(s: String) -> Self { Self(s) } } impl_string_common!($name); }; } macro_rules! simple_string_newtype_no_sqlx { ( $(#[$meta:meta])* $vis:vis struct $name:ident; ) => { $(#[$meta])* #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] $vis struct $name(String); impl $name { pub fn new(s: impl Into) -> Self { Self(s.into()) } } impl From for $name { fn from(s: String) -> Self { Self(s) } } impl_string_common!($name); }; } macro_rules! validated_string_newtype { ( $(#[$meta:meta])* $vis:vis struct $name:ident; error = $error:ident; label = $label:expr; validator = $validator:expr; ) => { $(#[$meta])* #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, sqlx::Type)] #[serde(transparent)] #[sqlx(transparent)] $vis struct $name(String); impl<'de> Deserialize<'de> for $name { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; $name::new(&s).map_err(|e| serde::de::Error::custom(e.to_string())) } } impl $name { pub fn new(s: impl Into) -> Result { let s = s.into(); // The validator gives back the string it accepted rather than unit // because the jacquard-common 0.9 that tranquil has for now // normalizes as it parses and strips `at://` prefix at the same time, // so storing the caller's input instead would leave Did::new(x).as_str() // and x in a split-brain bork. let validator: fn(&str) -> Result = $validator; match validator(&s) { Ok(validated) => Ok(Self(validated)), Err(()) => Err($error::Invalid(s)), } } } impl FromStr for $name { type Err = $error; fn from_str(s: &str) -> Result { Self::new(s) } } impl_string_common!($name); #[derive(Debug, Clone)] pub enum $error { Invalid(String), } impl std::fmt::Display for $error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Invalid(s) => write!(f, concat!("invalid ", $label, ": {}"), s), } } } impl std::error::Error for $error {} }; } validated_string_newtype! { pub struct Did; error = DidError; label = "DID"; validator = |s| jacquard_common::types::string::Did::new(s).map(|v| v.as_str().to_owned()).map_err(|_| ()); } impl Did { pub fn is_plc(&self) -> bool { self.0.starts_with("did:plc:") } pub fn is_web(&self) -> bool { self.0.starts_with("did:web:") } } const DID_REF_MAX_LEN: usize = 2048; const SERVICE_ID_MAX_LEN: usize = 128; const fn is_pchar(b: u8) -> bool { b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') } fn is_service_id(s: &str) -> bool { !s.is_empty() && s.len() <= SERVICE_ID_MAX_LEN && s.bytes().all(is_pchar) } validated_string_newtype! { pub struct DidRef; error = DidRefError; label = "DID reference"; validator = |s| { if s.len() > DID_REF_MAX_LEN { return Err(()); } match s.split_once('#') { None => jacquard_common::types::string::Did::new(s) .map(|v| v.as_str().to_owned()) .map_err(|_| ()), Some((did, service_id)) => { if !is_service_id(service_id) { return Err(()); } let base = jacquard_common::types::string::Did::new(did).map_err(|_| ())?; Ok(format!("{}#{}", base.as_str(), service_id)) } } }; } impl From for DidRef { fn from(did: Did) -> Self { Self(did.0) } } impl From<&Did> for DidRef { fn from(did: &Did) -> Self { Self(did.0.clone()) } } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, sqlx::Type)] #[serde(transparent)] #[sqlx(transparent)] pub struct Handle(String); impl<'de> Deserialize<'de> for Handle { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; Handle::new(&s).map_err(|e| serde::de::Error::custom(e.to_string())) } } impl Handle { // In jacquard-common 0.9, `Handle::new` strips the prefix // and checks the regex but then seems to reject every reserved TLD // except literal "handle.invalid" // where instead we need all of them to parse // such that validation can report `DisallowedTld` against them. // // And jacquard hands the stripped slice back without ever lowercasing it // so lowercasing here gets `Eq` & `Hash` agreeing for a given account. pub fn new(s: impl Into) -> Result { let s = s.into(); let stripped = s .strip_prefix("at://") .or_else(|| s.strip_prefix('@')) .unwrap_or(&s); match stripped.len() <= 253 && jacquard_common::types::handle::HANDLE_REGEX.is_match(stripped) { true => Ok(Self(stripped.to_ascii_lowercase())), false => Err(HandleError::Invalid(s)), } } pub fn has_disallowed_tld(&self) -> bool { jacquard_common::types::ends_with(&self.0, jacquard_common::types::DISALLOWED_TLDS) } } impl FromStr for Handle { type Err = HandleError; fn from_str(s: &str) -> Result { Self::new(s) } } impl_string_common!(Handle); #[derive(Debug, Clone, thiserror::Error)] pub enum HandleError { #[error("invalid handle: {0}")] Invalid(String), } validated_string_newtype! { pub struct Domain; error = DomainError; label = "domain"; validator = |s| { let normalized = s.to_ascii_lowercase(); (normalized.len() <= 253 && !normalized.is_empty() && normalized.split('.').all(|label| { !label.is_empty() && !label.starts_with('-') && !label.ends_with('-') && label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') })) .then_some(normalized) .ok_or(()) }; } impl Domain { pub fn eq_name(&self, name: &str) -> bool { self.as_str().eq_ignore_ascii_case(name) } pub fn strip_from<'h>(&self, handle: &'h str) -> Option<&'h str> { let domain_len = self.as_str().len(); (handle.len() > domain_len + 1 && handle.as_bytes()[handle.len() - domain_len - 1] == b'.') .then(|| &handle[..handle.len() - domain_len - 1]) .filter(|_| handle[handle.len() - domain_len..].eq_ignore_ascii_case(self.as_str())) } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum AtIdentifier { Did(Did), Handle(Handle), } impl AtIdentifier { pub fn new(s: impl AsRef) -> Result { let s = s.as_ref(); if s.starts_with("did:") { Did::new(s) .map(AtIdentifier::Did) .map_err(|_| AtIdentifierError::Invalid(s.to_string())) } else { Handle::new(s) .map(AtIdentifier::Handle) .map_err(|_| AtIdentifierError::Invalid(s.to_string())) } } pub fn as_str(&self) -> &str { match self { AtIdentifier::Did(d) => d.as_str(), AtIdentifier::Handle(h) => h.as_str(), } } pub fn into_inner(self) -> String { match self { AtIdentifier::Did(d) => d.into_inner(), AtIdentifier::Handle(h) => h.into_inner(), } } pub fn is_did(&self) -> bool { matches!(self, AtIdentifier::Did(_)) } pub fn is_handle(&self) -> bool { matches!(self, AtIdentifier::Handle(_)) } pub fn as_did(&self) -> Option<&Did> { match self { AtIdentifier::Did(d) => Some(d), AtIdentifier::Handle(_) => None, } } pub fn as_handle(&self) -> Option<&Handle> { match self { AtIdentifier::Handle(h) => Some(h), AtIdentifier::Did(_) => None, } } } impl From for AtIdentifier { fn from(did: Did) -> Self { AtIdentifier::Did(did) } } impl From for AtIdentifier { fn from(handle: Handle) -> Self { AtIdentifier::Handle(handle) } } impl AsRef for AtIdentifier { fn as_ref(&self) -> &str { self.as_str() } } impl Deref for AtIdentifier { type Target = str; fn deref(&self) -> &Self::Target { self.as_str() } } impl fmt::Display for AtIdentifier { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } impl Serialize for AtIdentifier { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(self.as_str()) } } impl<'de> Deserialize<'de> for AtIdentifier { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; AtIdentifier::new(&s).map_err(serde::de::Error::custom) } } #[derive(Debug, Clone, thiserror::Error)] pub enum AtIdentifierError { #[error("invalid AT identifier: {0}")] Invalid(String), } validated_string_newtype! { pub struct Rkey; error = RkeyError; label = "rkey"; validator = |s| jacquard_common::types::string::Rkey::new(s).map(|v| v.as_str().to_owned()).map_err(|_| ()); } impl Rkey { pub fn generate() -> Self { use jacquard_common::types::integer::LimitedU32; Self(jacquard_common::types::string::Tid::now(LimitedU32::MIN).to_string()) } pub fn is_tid(&self) -> bool { Tid::new(&self.0).is_ok() } pub fn to_tid(&self) -> Option { Tid::new(&self.0).ok() } } validated_string_newtype! { pub struct Nsid; error = NsidError; label = "NSID"; validator = |s| jacquard_common::types::string::Nsid::new(s).map(|v| v.as_str().to_owned()).map_err(|_| ()); } impl Nsid { pub fn authority(&self) -> &str { self.0.split('.').rev().nth(1).unwrap_or("") } pub fn name(&self) -> &str { self.0.split('.').next_back().unwrap_or("") } } validated_string_newtype! { pub struct AtUri; error = AtUriError; label = "AT URI"; validator = |s| jacquard_common::types::string::AtUri::new(s).map(|v| v.as_str().to_owned()).map_err(|_| ()); } impl AtUri { pub fn from_parts(did: &Did, collection: &Nsid, rkey: &Rkey) -> Self { Self(format!("at://{}/{}/{}", did, collection, rkey)) } pub fn did(&self) -> Option<&str> { self.0 .strip_prefix("at://") .and_then(|s| s.split('/').next()) } pub fn collection(&self) -> Option<&str> { self.0 .strip_prefix("at://") .and_then(|s| s.split('/').nth(1)) } pub fn rkey(&self) -> Option<&str> { self.0 .strip_prefix("at://") .and_then(|s| s.split('/').nth(2)) } } validated_string_newtype! { pub struct Tid; error = TidError; label = "TID"; validator = |s| jacquard_common::types::string::Tid::from_str(s).map(|v| v.as_str().to_owned()).map_err(|_| ()); } impl Tid { pub fn now() -> Self { use jacquard_common::types::integer::LimitedU32; Self(jacquard_common::types::string::Tid::now(LimitedU32::MIN).to_string()) } // TIDs sort like plain strings over base32-sortable // whose lowest character is '2' // so 13 of those is the smallest well-formed TID // I can make. // Any sentinel outside base32 would sort lower still // (cough cough how I used to do it when being lazy with "0") // but can't round-trip through `Tid::new`. pub fn earliest() -> Self { Self("2222222222222".to_owned()) } } impl From for Tid { fn from(tid: jacquard_common::types::string::Tid) -> Self { Self(tid.to_string()) } } validated_string_newtype! { pub struct Datetime; error = DatetimeError; label = "datetime"; validator = |s| jacquard_common::types::string::Datetime::from_str(s).map(|v| v.as_str().to_owned()).map_err(|_| ()); } impl Datetime { pub fn now() -> Self { Self(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true)) } pub fn to_chrono(&self) -> Option> { chrono::DateTime::parse_from_rfc3339(&self.0) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)) } } validated_string_newtype! { pub struct Language; error = LanguageError; label = "language"; validator = |s| jacquard_common::types::string::Language::from_str(s).map(|v| v.as_str().to_owned()).map_err(|_| ()); } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, sqlx::Type)] #[serde(transparent)] #[sqlx(transparent)] pub struct CidLink(String); impl<'de> Deserialize<'de> for CidLink { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; CidLink::new(&s).map_err(|e| serde::de::Error::custom(e.to_string())) } } impl CidLink { pub fn new(s: impl Into) -> Result { let s = s.into(); cid::Cid::from_str(&s).map_err(|_| CidLinkError::Invalid(s.clone()))?; Ok(Self(s)) } pub fn from_cid(cid: &cid::Cid) -> Self { Self(cid.to_string()) } pub fn to_cid(&self) -> Option { cid::Cid::from_str(&self.0).ok() } } impl From for CidLink { fn from(cid: cid::Cid) -> Self { Self(cid.to_string()) } } impl From<&cid::Cid> for CidLink { fn from(cid: &cid::Cid) -> Self { Self(cid.to_string()) } } impl FromStr for CidLink { type Err = CidLinkError; fn from_str(s: &str) -> Result { Self::new(s) } } impl_string_common!(CidLink); #[derive(Debug, Clone, thiserror::Error)] pub enum CidLinkError { #[error("invalid CID: {0}")] Invalid(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub enum AccountState { Active, Deactivated { at: chrono::DateTime, }, TakenDown { reference: String, }, Migrated { at: chrono::DateTime, to_pds: String, }, } impl AccountState { pub fn from_db_fields( deactivated_at: Option>, takedown_ref: Option, migrated_to_pds: Option, migrated_at: Option>, ) -> Self { if let Some(reference) = takedown_ref { AccountState::TakenDown { reference } } else if let (Some(at), Some(to_pds)) = (deactivated_at, migrated_to_pds) { let migrated_at = migrated_at.unwrap_or(at); AccountState::Migrated { at: migrated_at, to_pds, } } else if let Some(at) = deactivated_at { AccountState::Deactivated { at } } else { AccountState::Active } } pub fn is_active(&self) -> bool { matches!(self, AccountState::Active) } pub fn is_deactivated(&self) -> bool { matches!(self, AccountState::Deactivated { .. }) } pub fn is_takendown(&self) -> bool { matches!(self, AccountState::TakenDown { .. }) } pub fn is_migrated(&self) -> bool { matches!(self, AccountState::Migrated { .. }) } pub fn can_login(&self) -> bool { matches!(self, AccountState::Active) } pub fn can_access_repo(&self) -> bool { matches!( self, AccountState::Active | AccountState::Deactivated { .. } ) } pub fn status_string(&self) -> &'static str { match self { AccountState::Active => "active", AccountState::Deactivated { .. } => "deactivated", AccountState::TakenDown { .. } => "takendown", AccountState::Migrated { .. } => "deactivated", } } pub fn status_for_session(&self) -> Option<&'static str> { match self { AccountState::Active => None, AccountState::Deactivated { .. } => Some("deactivated"), AccountState::TakenDown { .. } => Some("takendown"), AccountState::Migrated { .. } => Some("migrated"), } } } impl fmt::Display for AccountState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AccountState::Active => write!(f, "active"), AccountState::Deactivated { at } => write!(f, "deactivated ({})", at), AccountState::TakenDown { reference } => write!(f, "takendown ({})", reference), AccountState::Migrated { to_pds, .. } => write!(f, "migrated to {}", to_pds), } } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(transparent)] pub struct PlainPassword(String); impl PlainPassword { pub fn new(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } pub fn into_inner(self) -> String { self.0 } pub fn is_empty(&self) -> bool { self.0.is_empty() } } impl AsRef for PlainPassword { fn as_ref(&self) -> &str { &self.0 } } impl AsRef<[u8]> for PlainPassword { fn as_ref(&self) -> &[u8] { self.0.as_bytes() } } impl Deref for PlainPassword { type Target = str; fn deref(&self) -> &Self::Target { &self.0 } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[serde(transparent)] #[sqlx(transparent)] pub struct PasswordHash(String); impl PasswordHash { pub fn new(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } } impl AsRef for PasswordHash { fn as_ref(&self) -> &str { &self.0 } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum TokenSource { #[serde(rename = "session")] Session, #[serde(rename = "oauth")] OAuth { client_id: ClientId, scope: String }, #[serde(rename = "service_auth")] ServiceAuth { aud: Did, lxm: Option }, } impl TokenSource { pub fn is_session(&self) -> bool { matches!(self, TokenSource::Session) } pub fn is_oauth(&self) -> bool { matches!(self, TokenSource::OAuth { .. }) } pub fn is_service_auth(&self) -> bool { matches!(self, TokenSource::ServiceAuth { .. }) } } simple_string_newtype_no_sqlx! { pub struct JwkThumbprint; } simple_string_newtype_no_sqlx! { pub struct DPoPProofId; } simple_string_newtype! { pub struct TokenId; } impl TokenId { pub fn generate() -> Self { Self(uuid::Uuid::new_v4().to_string()) } } simple_string_newtype! { pub struct ClientId; } simple_string_newtype! { pub struct DeviceId; } impl DeviceId { pub fn generate() -> Self { Self(uuid::Uuid::new_v4().to_string()) } } simple_string_newtype! { pub struct RequestId; } impl RequestId { pub fn generate() -> Self { Self(format!( "urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4() )) } } simple_string_newtype! { pub struct Jti; } simple_string_newtype_no_sqlx! { pub struct CrossPdsState; } simple_string_newtype! { pub struct AuthorizationCode; } impl AuthorizationCode { pub fn generate() -> Self { Self(generate_url_safe_secret()) } } simple_string_newtype! { pub struct RefreshToken; } impl RefreshToken { pub fn generate() -> Self { Self(generate_url_safe_secret()) } } fn generate_url_safe_secret() -> String { use rand::Rng; let bytes: [u8; 32] = rand::thread_rng().r#gen(); base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes) } simple_string_newtype! { pub struct InviteCode; } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "snake_case")] #[sqlx(type_name = "comms_channel", rename_all = "snake_case")] #[derive(Copy)] pub enum CommsChannel { Email, Discord, Telegram, Signal, } impl CommsChannel { pub fn as_str(&self) -> &'static str { match self { CommsChannel::Email => "email", CommsChannel::Discord => "discord", CommsChannel::Telegram => "telegram", CommsChannel::Signal => "signal", } } pub fn from_str_opt(s: &str) -> Option { match s { "email" => Some(CommsChannel::Email), "discord" => Some(CommsChannel::Discord), "telegram" => Some(CommsChannel::Telegram), "signal" => Some(CommsChannel::Signal), _ => None, } } } impl fmt::Display for CommsChannel { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HostReach { Global, Loopback, Private, } fn ipv4_reach(ip: std::net::Ipv4Addr) -> HostReach { let [a, b, c, _] = ip.octets(); match ip { _ if ip.is_loopback() => HostReach::Loopback, _ if ip.is_private() || ip.is_link_local() || ip.is_multicast() || ip.is_documentation() || a == 0 || a == 100 && (64..128).contains(&b) || a == 192 && b == 0 && c == 0 || a == 192 && b == 88 && c == 99 || a == 198 && (18..20).contains(&b) || a & 0xf0 == 240 => { HostReach::Private } _ => HostReach::Global, } } fn ipv6_reach(ip: std::net::Ipv6Addr) -> HostReach { let seg = ip.segments(); let embedded_ipv4 = |hi: u16, lo: u16| std::net::Ipv4Addr::from((u32::from(hi) << 16) | u32::from(lo)); match ip.to_ipv4_mapped() { Some(mapped) => ipv4_reach(mapped), None => match ip { _ if ip.is_loopback() => HostReach::Loopback, _ if seg[..6] == [0, 0, 0, 0, 0, 0] => ipv4_reach(embedded_ipv4(seg[6], seg[7])), _ if seg[..2] == [0x2001, 0] => ipv4_reach(embedded_ipv4(!seg[6], !seg[7])), _ if seg[0] == 0x2002 => ipv4_reach(embedded_ipv4(seg[1], seg[2])), _ if seg[..6] == [0x64, 0xff9b, 0, 0, 0, 0] => { ipv4_reach(embedded_ipv4(seg[6], seg[7])) } _ if seg[..3] == [0x64, 0xff9b, 1] => HostReach::Private, _ if ip.is_unspecified() || ip.is_multicast() || seg[0] & 0xfe00 == 0xfc00 || seg[0] & 0xffc0 == 0xfe80 || seg[..2] == [0x2001, 0x0db8] => { HostReach::Private } _ => HostReach::Global, }, } } fn host_reach(host: url::Host<&str>) -> HostReach { match host { url::Host::Ipv4(ip) => ipv4_reach(ip), url::Host::Ipv6(ip) => ipv6_reach(ip), url::Host::Domain(name) => { let name = name.trim_end_matches('.').to_ascii_lowercase(); match name.as_str() { "localhost" => HostReach::Loopback, _ if name.ends_with(".localhost") => HostReach::Loopback, _ if name.ends_with(".local") || name.ends_with(".internal") || name.ends_with(".home.arpa") || name == "home.arpa" => { HostReach::Private } _ => HostReach::Global, } } } } pub fn url_reach(url: &url::Url) -> Option { url.host().map(host_reach) } pub fn ip_reach(ip: std::net::IpAddr) -> HostReach { match ip { std::net::IpAddr::V4(v4) => ipv4_reach(v4), std::net::IpAddr::V6(v6) => ipv6_reach(v6), } } pub fn reach_permits(reach: HostReach, policy: ReachPolicy) -> bool { matches!( (reach, policy), (HostReach::Global, _) | ( HostReach::Loopback, ReachPolicy::AllowLoopback | ReachPolicy::AllowPrivate, ) | (HostReach::Private, ReachPolicy::AllowPrivate) ) } pub fn url_reach_permits(url: &url::Url, policy: ReachPolicy) -> bool { let Some(reach) = url_reach(url) else { return false; }; let scheme_permits = matches!( (url.scheme(), reach), ("https", _) | ("http", HostReach::Loopback | HostReach::Private) ); scheme_permits && reach_permits(reach, policy) } fn parse_http_url(s: &str, policy: ReachPolicy, allow_query: bool) -> Option { let parsed = url::Url::parse(s).ok()?; let rejected = (parsed.query().is_some() && !allow_query) || parsed.fragment().is_some() || !parsed.username().is_empty() || parsed.password().is_some() || !url_reach_permits(&parsed, policy); match rejected { true => None, false => Some(parsed), } } const REDIRECT_HOP_LIMIT: usize = 5; pub fn redirect_policy(policy: ReachPolicy) -> reqwest::redirect::Policy { reqwest::redirect::Policy::custom(move |attempt| { let over_limit = attempt.previous().len() > REDIRECT_HOP_LIMIT; let permitted = url_reach_permits(attempt.url(), policy); let target = attempt.url().clone(); match (over_limit, permitted) { (true, _) => attempt.error(format!("more than {} redirect hops", REDIRECT_HOP_LIMIT)), (false, false) => attempt.error(format!( "redirect target {} is outside the allowed host reach", target )), (false, true) => attempt.follow(), } }) } pub struct ReachGuardedDns(ReachPolicy); impl reqwest::dns::Resolve for ReachGuardedDns { fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { let policy = self.0; Box::pin(async move { let host = name.as_str().to_owned(); let permitted: Vec = tokio::net::lookup_host((host.as_str(), 0)) .await? .filter(|addr| reach_permits(ip_reach(addr.ip()), policy)) .collect(); match permitted.is_empty() { true => Err(format!( "no resolved address for {} is inside the allowed host reach", host ) .into()), false => Ok(Box::new(permitted.into_iter()) as reqwest::dns::Addrs), } }) } } pub fn dns_guard(policy: ReachPolicy) -> std::sync::Arc { std::sync::Arc::new(ReachGuardedDns(policy)) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReachPolicy { AllowLoopback, AllowPrivate, GlobalOnly, } impl ReachPolicy { #[cfg(debug_assertions)] pub const DEBUG_LOOPBACK: ReachPolicy = ReachPolicy::AllowLoopback; #[cfg(not(debug_assertions))] pub const DEBUG_LOOPBACK: ReachPolicy = ReachPolicy::GlobalOnly; pub fn from_private_fetch(allow_private: bool) -> ReachPolicy { match allow_private { true => ReachPolicy::AllowPrivate, false => ReachPolicy::DEBUG_LOOPBACK, } } } pub trait UrlKind { const LABEL: &'static str; const REACH_POLICY: ReachPolicy; const ALLOW_QUERY: bool; } pub mod url_kind { use super::{ReachPolicy, UrlKind}; pub struct AuthServerEndpoint; impl UrlKind for AuthServerEndpoint { const LABEL: &'static str = "authorization server endpoint"; const REACH_POLICY: ReachPolicy = ReachPolicy::GlobalOnly; const ALLOW_QUERY: bool = true; } pub struct Issuer; impl UrlKind for Issuer { const LABEL: &'static str = "issuer"; const REACH_POLICY: ReachPolicy = ReachPolicy::GlobalOnly; const ALLOW_QUERY: bool = false; } pub struct Jwks; impl UrlKind for Jwks { const LABEL: &'static str = "JWKS URI"; const REACH_POLICY: ReachPolicy = ReachPolicy::DEBUG_LOOPBACK; const ALLOW_QUERY: bool = true; } pub struct Pds; impl UrlKind for Pds { const LABEL: &'static str = "PDS URL"; const REACH_POLICY: ReachPolicy = ReachPolicy::GlobalOnly; const ALLOW_QUERY: bool = false; } pub struct SchemaHost; impl UrlKind for SchemaHost { const LABEL: &'static str = "schema host URL"; const REACH_POLICY: ReachPolicy = ReachPolicy::DEBUG_LOOPBACK; const ALLOW_QUERY: bool = false; } pub struct SsoIssuer; impl UrlKind for SsoIssuer { const LABEL: &'static str = "SSO issuer"; const REACH_POLICY: ReachPolicy = ReachPolicy::AllowPrivate; const ALLOW_QUERY: bool = false; } pub struct SsoJwks; impl UrlKind for SsoJwks { const LABEL: &'static str = "SSO JWKS URI"; const REACH_POLICY: ReachPolicy = ReachPolicy::AllowPrivate; const ALLOW_QUERY: bool = true; } } pub struct HttpUrl { raw: String, parsed: url::Url, kind: PhantomData K>, } pub type AuthServerEndpoint = HttpUrl; pub type Issuer = HttpUrl; pub type JwksUri = HttpUrl; pub type PdsUrl = HttpUrl; pub type SchemaHostUrl = HttpUrl; pub type SsoIssuer = HttpUrl; pub type SsoJwksUri = HttpUrl; #[derive(Debug, Clone, PartialEq, Eq)] pub struct InvalidHttpUrl { pub kind: &'static str, pub value: String, } impl fmt::Display for InvalidHttpUrl { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "invalid {}: {}", self.kind, self.value) } } impl std::error::Error for InvalidHttpUrl {} impl HttpUrl { pub fn new(s: impl Into) -> Result { let raw = s.into(); match parse_http_url(&raw, K::REACH_POLICY, K::ALLOW_QUERY) { Some(parsed) => Ok(Self { raw, parsed, kind: PhantomData, }), None => Err(InvalidHttpUrl { kind: K::LABEL, value: raw, }), } } /// The URL as given. /// OIDC & OAuth define issuer comparison as an /// exact string match, /// so anything sent to or compared against a peer uses this. /// Give it to us raw & wriggling!! pub fn as_str(&self) -> &str { &self.raw } /// The parsed form: lowercased scheme and host, with `/` for a bare authority. /// Cache keys use this so `https://oyster.cafe` and `https://oyster.cafe/` share one entry. pub fn canonical(&self) -> &str { self.parsed.as_str() } pub fn url(&self) -> &url::Url { &self.parsed } pub fn endpoint(&self, path: &str) -> url::Url { let mut url = self.parsed.clone(); let base = url.path().trim_end_matches('/').to_owned(); url.set_path(&format!("{}/{}", base, path.trim_start_matches('/'))); url } } pub mod http_url { use super::{HttpUrl, UrlKind}; use serde::Deserialize; pub fn deserialize_optional<'de, D, K>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, K: UrlKind, { Ok(Option::::deserialize(deserializer)?.and_then(|s| { HttpUrl::new(s) .inspect_err(|e| tracing::warn!(error = %e, "discarding unusable URL field")) .ok() })) } } impl FromStr for HttpUrl { type Err = InvalidHttpUrl; fn from_str(s: &str) -> Result { Self::new(s) } } impl fmt::Debug for HttpUrl { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}({})", K::LABEL, self.raw) } } impl fmt::Display for HttpUrl { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.raw) } } impl Clone for HttpUrl { fn clone(&self) -> Self { Self { raw: self.raw.clone(), parsed: self.parsed.clone(), kind: PhantomData, } } } impl PartialEq for HttpUrl { fn eq(&self, other: &Self) -> bool { self.parsed == other.parsed } } impl Eq for HttpUrl {} impl Hash for HttpUrl { fn hash(&self, state: &mut H) { self.parsed.hash(state); } } impl Serialize for HttpUrl { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.raw) } } impl<'de, K: UrlKind> Deserialize<'de> for HttpUrl { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; Self::new(s).map_err(|e| serde::de::Error::custom(e.to_string())) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EmailTokenPurpose { UpdateEmail, ConfirmEmail, DeleteAccount, ResetPassword, PlcOperation, } impl EmailTokenPurpose { pub fn as_str(&self) -> &'static str { match self { Self::UpdateEmail => "update_email", Self::ConfirmEmail => "confirm_email", Self::DeleteAccount => "delete_account", Self::ResetPassword => "reset_password", Self::PlcOperation => "plc_operation", } } } impl fmt::Display for EmailTokenPurpose { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "snake_case")] #[sqlx(type_name = "comms_type", rename_all = "snake_case")] pub enum CommsType { Verification, PasswordReset, AccountDeleted, AccountMigrated, PasskeyRecovery, MigrationVerification, } pub mod did_doc { use crate::{HttpUrl, InvalidHttpUrl, UrlKind}; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum PdsEndpointError { #[error("DID document has no atproto PDS service entry")] Missing, #[error(transparent)] Invalid(#[from] InvalidHttpUrl), } pub fn extract_pds_endpoint( doc: &serde_json::Value, ) -> Result, PdsEndpointError> { doc.get("service") .and_then(|s| s.as_array()) .and_then(|services| { services.iter().find_map(|svc| { let id = svc.get("id").and_then(|v| v.as_str()).unwrap_or_default(); let svc_type = svc.get("type").and_then(|v| v.as_str()).unwrap_or_default(); ((id == "#atproto_pds" || id.ends_with("#atproto_pds")) && svc_type == "AtprotoPersonalDataServer") .then(|| svc.get("serviceEndpoint").and_then(|v| v.as_str()))? }) }) .ok_or(PdsEndpointError::Missing) .and_then(|endpoint| HttpUrl::new(endpoint).map_err(PdsEndpointError::Invalid)) } pub fn extract_handle(doc: &serde_json::Value) -> Option { doc.get("alsoKnownAs") .and_then(|a| a.as_array()) .and_then(|aliases| { aliases.iter().find_map(|alias| { alias .as_str() .and_then(|s| s.strip_prefix("at://")) .and_then(|h| crate::Handle::new(h).ok()) }) }) } } #[cfg(test)] mod http_url_tests { use super::did_doc::{PdsEndpointError, extract_pds_endpoint}; use super::{ AuthServerEndpoint, Issuer, JwksUri, PdsUrl, SchemaHostUrl, SsoIssuer, SsoJwksUri, }; #[test] fn extract_pds_endpoint_selects_the_pds_service_and_reports_missing_or_invalid() { let labeler = serde_json::json!({ "id": "#atproto_labeler", "type": "AtprotoLabeler", "serviceEndpoint": "https://labeler.nel.pet" }); let pds = |endpoint: &str| { serde_json::json!({ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": endpoint }) }; let both = serde_json::json!({ "service": [labeler.clone(), pds("https://oyster.cafe")] }); assert_eq!( extract_pds_endpoint::(&both) .unwrap() .as_str(), "https://oyster.cafe" ); [ serde_json::json!({ "service": [labeler] }), serde_json::json!({}), ] .iter() .for_each(|doc| { assert_eq!( extract_pds_endpoint::(doc).unwrap_err(), PdsEndpointError::Missing ); }); let plain_http = serde_json::json!({ "service": [pds("http://oyster.cafe")] }); assert!(matches!( extract_pds_endpoint::(&plain_http), Err(PdsEndpointError::Invalid(_)) )); } #[test] fn pds_and_jwks_kinds_reject_private_and_reserved_addresses() { [ "https://10.0.0.1", "https://192.168.1.1", "https://172.16.0.1", "https://169.254.169.254/latest/meta-data", "https://100.64.0.1", "https://0.1.2.3", "https://192.0.0.8", "https://192.88.99.1", "https://[fd00::1]", "https://[fe80::1]", "https://[ff02::1]", "https://[::ffff:10.0.0.1]", "https://[64:ff9b::a00:1]", "https://[64:ff9b:1::1]", "https://[2002:a00:1::]", "https://[::10.0.0.1]", "https://[2001:0:0:0:0:0:f5ff:fffe]", "https://kelp.internal", "https://whelk.local", "https://limpet.home.arpa", ] .iter() .for_each(|url| { assert!(PdsUrl::new(*url).is_err(), "PdsUrl must reject {url}"); assert!(JwksUri::new(*url).is_err(), "JwksUri must reject {url}"); }); [ "https://oyster.cafe", "https://[64:ff9b::808:808]", "https://[::8.8.8.8]", "https://[2001::f7f7:f7f7]", ] .iter() .for_each(|url| assert!(PdsUrl::new(*url).is_ok(), "PdsUrl must accept {url}")); } #[test] fn each_kind_applies_its_own_local_host_policy() { assert!(PdsUrl::new("http://127.0.0.1:2583").is_err()); assert!(PdsUrl::new("https://localhost").is_err()); assert!(Issuer::new("http://localhost:8080").is_err()); assert_eq!( JwksUri::new("http://localhost:8080/keys").is_ok(), cfg!(debug_assertions) ); assert_eq!( SchemaHostUrl::new("http://127.0.0.1:2583").is_ok(), cfg!(debug_assertions) ); assert!(SsoJwksUri::new("http://127.0.0.1:8080/keys").is_ok()); assert!(SsoJwksUri::new("http://[::1]:8080/keys").is_ok()); assert!(SsoJwksUri::new("http://squid.localhost:8080/keys").is_ok()); assert!(SsoJwksUri::new("https://keycloak.internal/keys?client=squid").is_ok()); assert!(SsoJwksUri::new("http://oyster.cafe/keys").is_err()); assert!(SsoIssuer::new("https://keycloak.internal/realms/uni").is_ok()); assert!(SsoIssuer::new("http://10.0.0.5:8080").is_ok()); assert!(SsoIssuer::new("http://localhost:8080").is_ok()); assert!(SsoIssuer::new("http://oyster.cafe").is_err()); assert!(AuthServerEndpoint::new("https://oyster.cafe/oauth/par?tenant=uni").is_ok()); [ "https://169.254.169.254/oauth/par", "https://[fd00::1]/oauth/par", "http://127.0.0.1:2583/oauth/par", "http://oyster.cafe/oauth/par", ] .iter() .for_each(|url| { assert!( AuthServerEndpoint::new(*url).is_err(), "AuthServerEndpoint must reject {url}" ); }); } #[test] fn canonicalization_keeps_identity_and_rejects_query_fragment_and_userinfo() { assert_eq!( PdsUrl::new("HTTPS://oyster.cafe").unwrap().canonical(), "https://oyster.cafe/" ); let bare = PdsUrl::new("https://oyster.cafe").unwrap(); let slashed = PdsUrl::new("https://oyster.cafe/").unwrap(); assert_eq!(bare, slashed); assert_eq!(bare.canonical(), slashed.canonical()); let issuer = Issuer::new("https://accounts.google.com").unwrap(); assert_eq!(issuer.as_str(), "https://accounts.google.com"); assert_eq!(issuer.canonical(), "https://accounts.google.com/"); assert_eq!( PdsUrl::new("https://oyster.cafe/pds/") .unwrap() .endpoint(".well-known/oauth-protected-resource") .as_str(), "https://oyster.cafe/pds/.well-known/oauth-protected-resource" ); assert_eq!( JwksUri::new("https://oyster.cafe/keys?appid=abc") .expect("JwksUri keeps the query") .canonical(), "https://oyster.cafe/keys?appid=abc" ); assert!(PdsUrl::new("https://oyster.cafe/?x=1").is_err()); assert!(PdsUrl::new("https://oyster.cafe/#frag").is_err()); assert!(PdsUrl::new("https://nel:pw@oyster.cafe").is_err()); assert!(Issuer::new("https://oyster.cafe/?x=1").is_err()); assert!(JwksUri::new("https://oyster.cafe/keys#frag").is_err()); } } #[cfg(test)] mod dns_guard_tests { use super::{ReachPolicy, dns_guard}; use reqwest::dns::Resolve; #[tokio::test] async fn the_policy_gates_loopback_resolution() { let name = |host: &str| host.parse::().expect("valid hostname"); assert!( dns_guard(ReachPolicy::GlobalOnly) .resolve(name("localhost")) .await .is_err() ); let addrs: Vec<_> = dns_guard(ReachPolicy::AllowLoopback) .resolve(name("localhost")) .await .expect("localhost resolves") .collect(); assert!(!addrs.is_empty()); assert!(addrs.iter().all(|a| a.ip().is_loopback())); } } #[cfg(test)] mod validated_newtype_tests { use super::*; #[test] fn a_did_stores_the_string_the_validator_accepted() { assert_eq!( Did::new("at://did:plc:nel").unwrap().as_str(), "did:plc:nel", "jacquard validates the at:// stripped form, so the stripped form must be stored" ); assert_eq!(Did::new("did:plc:nel").unwrap().as_str(), "did:plc:nel"); } #[test] fn a_handle_stores_the_string_the_validator_accepted() { assert_eq!(Handle::new("@nel.pet").unwrap().as_str(), "nel.pet"); assert_eq!(Handle::new("at://nel.pet").unwrap().as_str(), "nel.pet"); assert_eq!(Handle::new("nel.pet").unwrap().as_str(), "nel.pet"); } #[test] fn a_handle_lowercases_on_construction() { assert_eq!(Handle::new("Nel.Pet").unwrap().as_str(), "nel.pet"); assert_eq!( Handle::new("WHELK.OYSTER.CAFE").unwrap(), Handle::new("whelk.oyster.cafe").unwrap() ); } #[test] fn a_prefixed_did_round_trips_through_its_own_constructor() { let once = Did::new("at://did:plc:whelk").unwrap(); let twice = Did::new(once.as_str()).unwrap(); assert_eq!(once, twice); } #[test] fn an_invalid_value_reports_the_string_it_was_given() { let err = Did::new("at://not-a-did").unwrap_err(); assert!( err.to_string().contains("at://not-a-did"), "the error names the caller's input, got {err}" ); } #[test] fn a_bare_did_ref_names_no_service() { let aud = DidRef::new("did:plc:abc").unwrap(); assert_eq!( aud.as_str(), "did:plc:abc", "an absent fragment is not the same as an empty one, so nothing may be appended" ); } #[test] fn a_did_ref_keeps_the_service_id_it_was_given() { let aud = DidRef::new("did:web:api.colibri.social#colibri_appview").unwrap(); assert_eq!( aud.as_str(), "did:web:api.colibri.social#colibri_appview", "the fragment is what tells the receiver which of its services was audienced, \ so it must survive entirely" ); } #[test] fn a_did_ref_normalizes_its_did_half_the_way_a_did_does() { assert_eq!( DidRef::new("at://did:plc:abc#colibri_appview") .unwrap() .as_str(), "did:plc:abc#colibri_appview" ); assert_eq!( DidRef::new("did:plc:def").unwrap().as_str(), Did::new("did:plc:def").unwrap().as_str(), "a fragmentless DidRef must be byte-identical to the Did it replaces" ); } #[test] fn a_did_ref_rejects_anything_that_cannot_name_one_service() { for bad in [ "did:web:oyster.cafe#", "did:web:oyster.cafe#a#b", "did:web:oyster.cafe# whelk", "did:web:oyster.cafe#a/b", "did:web:oyster.cafe#a?b", "did:web:oyster.cafe#