lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

authorization.rs (39999B)


      1 //! Pure BUD-11 authorization claim construction, parsing, and validation.
      2 //!
      3 //! This module validates bounded claim content and caller-supplied endpoint,
      4 //! server, hash, and time context. It does not read a clock, verify Nostr event
      5 //! signatures or authors, encode HTTP headers, send requests, or publish events.
      6 //! A [`ValidatedAuthorizationClaim`] therefore proves only the checks represented
      7 //! by its [`AuthorizationValidation`]; the composing Nostr and HTTP layers retain
      8 //! authentication and request-commit responsibility.
      9 
     10 use alloc::{
     11     format,
     12     string::{String, ToString},
     13     vec,
     14     vec::Vec,
     15 };
     16 use core::{fmt, net::Ipv6Addr, str::FromStr};
     17 
     18 use crate::{Error, Sha256};
     19 
     20 pub const RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND: u16 = 24_242;
     21 pub const RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES: usize = 4_096;
     22 pub const RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS: u64 = 300;
     23 pub const RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS: u64 = 300;
     24 
     25 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
     26 pub enum AuthorizationAction {
     27     Get,
     28     Upload,
     29     List,
     30     Delete,
     31     Media,
     32 }
     33 
     34 impl AuthorizationAction {
     35     pub fn parse(value: &str) -> Result<Self, Error> {
     36         match value {
     37             "get" => Ok(Self::Get),
     38             "upload" => Ok(Self::Upload),
     39             "list" => Ok(Self::List),
     40             "delete" => Ok(Self::Delete),
     41             "media" => Ok(Self::Media),
     42             _ => Err(Error::InvalidAuthorizationAction),
     43         }
     44     }
     45 
     46     pub const fn as_str(self) -> &'static str {
     47         match self {
     48             Self::Get => "get",
     49             Self::Upload => "upload",
     50             Self::List => "list",
     51             Self::Delete => "delete",
     52             Self::Media => "media",
     53         }
     54     }
     55 }
     56 
     57 impl fmt::Display for AuthorizationAction {
     58     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     59         formatter.write_str(self.as_str())
     60     }
     61 }
     62 
     63 impl FromStr for AuthorizationAction {
     64     type Err = Error;
     65 
     66     fn from_str(value: &str) -> Result<Self, Self::Err> {
     67         Self::parse(value)
     68     }
     69 }
     70 
     71 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
     72 pub struct ServerDomain(String);
     73 
     74 impl ServerDomain {
     75     pub fn parse(value: &str) -> Result<Self, Error> {
     76         if value.is_empty()
     77             || value.len() > 253
     78             || !value.is_ascii()
     79             || value.bytes().any(|byte| byte.is_ascii_uppercase())
     80         {
     81             return Err(Error::InvalidAuthorizationServerDomain);
     82         }
     83 
     84         if let Some(address) = value
     85             .strip_prefix('[')
     86             .and_then(|value| value.strip_suffix(']'))
     87         {
     88             let address = address
     89                 .parse::<Ipv6Addr>()
     90                 .map_err(|_| Error::InvalidAuthorizationServerDomain)?;
     91             if value != format!("[{address}]") {
     92                 return Err(Error::InvalidAuthorizationServerDomain);
     93             }
     94             return Ok(Self(value.to_string()));
     95         }
     96 
     97         let mut all_labels_are_numeric = true;
     98         for label in value.split('.') {
     99             if label.is_empty()
    100                 || label.len() > 63
    101                 || !label
    102                     .bytes()
    103                     .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
    104                 || !label
    105                     .as_bytes()
    106                     .first()
    107                     .is_some_and(u8::is_ascii_alphanumeric)
    108                 || !label
    109                     .as_bytes()
    110                     .last()
    111                     .is_some_and(u8::is_ascii_alphanumeric)
    112             {
    113                 return Err(Error::InvalidAuthorizationServerDomain);
    114             }
    115             all_labels_are_numeric &= label.bytes().all(|byte| byte.is_ascii_digit());
    116         }
    117 
    118         if all_labels_are_numeric && !is_canonical_dotted_ipv4(value) {
    119             return Err(Error::InvalidAuthorizationServerDomain);
    120         }
    121 
    122         Ok(Self(value.to_string()))
    123     }
    124 
    125     pub fn as_str(&self) -> &str {
    126         &self.0
    127     }
    128 }
    129 
    130 impl fmt::Display for ServerDomain {
    131     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    132         formatter.write_str(self.as_str())
    133     }
    134 }
    135 
    136 impl FromStr for ServerDomain {
    137     type Err = Error;
    138 
    139     fn from_str(value: &str) -> Result<Self, Self::Err> {
    140         Self::parse(value)
    141     }
    142 }
    143 
    144 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
    145 pub struct AuthorizationContent(String);
    146 
    147 impl AuthorizationContent {
    148     pub fn parse(value: &str) -> Result<Self, Error> {
    149         if value.is_empty()
    150             || value.len() > RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES
    151             || value.trim() != value
    152             || value
    153                 .chars()
    154                 .any(|character| character.is_control() && !matches!(character, '\t' | '\n' | '\r'))
    155         {
    156             return Err(Error::InvalidAuthorizationContent);
    157         }
    158         Ok(Self(value.to_string()))
    159     }
    160 
    161     pub fn as_str(&self) -> &str {
    162         &self.0
    163     }
    164 }
    165 
    166 impl fmt::Display for AuthorizationContent {
    167     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    168         formatter.write_str(self.as_str())
    169     }
    170 }
    171 
    172 impl FromStr for AuthorizationContent {
    173     type Err = Error;
    174 
    175     fn from_str(value: &str) -> Result<Self, Self::Err> {
    176         Self::parse(value)
    177     }
    178 }
    179 
    180 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
    181 pub enum AuthorizationTarget {
    182     GetBlob(Sha256),
    183     Upload(Sha256),
    184     List,
    185     DeleteBlob(Sha256),
    186     Mirror(Sha256),
    187     Media(Sha256),
    188 }
    189 
    190 impl AuthorizationTarget {
    191     pub const fn action(self) -> AuthorizationAction {
    192         match self {
    193             Self::GetBlob(_) => AuthorizationAction::Get,
    194             Self::Upload(_) | Self::Mirror(_) => AuthorizationAction::Upload,
    195             Self::List => AuthorizationAction::List,
    196             Self::DeleteBlob(_) => AuthorizationAction::Delete,
    197             Self::Media(_) => AuthorizationAction::Media,
    198         }
    199     }
    200 
    201     pub const fn implied_hash(self) -> Option<Sha256> {
    202         match self {
    203             Self::GetBlob(hash)
    204             | Self::Upload(hash)
    205             | Self::DeleteBlob(hash)
    206             | Self::Mirror(hash)
    207             | Self::Media(hash) => Some(hash),
    208             Self::List => None,
    209         }
    210     }
    211 
    212     const fn requires_hash_tag(self) -> bool {
    213         match self {
    214             Self::GetBlob(_) | Self::List => false,
    215             Self::Upload(_) | Self::DeleteBlob(_) | Self::Mirror(_) | Self::Media(_) => true,
    216         }
    217     }
    218 }
    219 
    220 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
    221 pub enum ServerScopeRequirement {
    222     OptionalAnyMatch,
    223     RequiredAnyMatch,
    224 }
    225 
    226 #[derive(Clone, Debug, PartialEq, Eq)]
    227 pub struct AuthorizationValidation {
    228     target: AuthorizationTarget,
    229     target_server: ServerDomain,
    230     server_scope_requirement: ServerScopeRequirement,
    231     now: u64,
    232     max_created_age_seconds: Option<u64>,
    233     max_lifetime_seconds: Option<u64>,
    234 }
    235 
    236 impl AuthorizationValidation {
    237     pub fn bud11(target: AuthorizationTarget, target_server: ServerDomain, now: u64) -> Self {
    238         Self {
    239             target,
    240             target_server,
    241             server_scope_requirement: ServerScopeRequirement::OptionalAnyMatch,
    242             now,
    243             max_created_age_seconds: None,
    244             max_lifetime_seconds: None,
    245         }
    246     }
    247 
    248     pub fn new(
    249         target: AuthorizationTarget,
    250         target_server: ServerDomain,
    251         server_scope_requirement: ServerScopeRequirement,
    252         now: u64,
    253         max_created_age_seconds: u64,
    254     ) -> Result<Self, Error> {
    255         if max_created_age_seconds > RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS {
    256             return Err(Error::InvalidAuthorizationCreatedAge);
    257         }
    258         Ok(Self {
    259             target,
    260             target_server,
    261             server_scope_requirement,
    262             now,
    263             max_created_age_seconds: Some(max_created_age_seconds),
    264             max_lifetime_seconds: Some(RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS),
    265         })
    266     }
    267 
    268     pub const fn target(&self) -> AuthorizationTarget {
    269         self.target
    270     }
    271 
    272     pub fn target_server(&self) -> &ServerDomain {
    273         &self.target_server
    274     }
    275 
    276     pub const fn server_scope_requirement(&self) -> ServerScopeRequirement {
    277         self.server_scope_requirement
    278     }
    279 
    280     pub const fn now(&self) -> u64 {
    281         self.now
    282     }
    283 
    284     pub const fn max_created_age_seconds(&self) -> Option<u64> {
    285         self.max_created_age_seconds
    286     }
    287 
    288     pub const fn max_lifetime_seconds(&self) -> Option<u64> {
    289         self.max_lifetime_seconds
    290     }
    291 }
    292 
    293 #[derive(Clone, Debug, PartialEq, Eq)]
    294 pub struct AuthorizationClaim {
    295     content: AuthorizationContent,
    296     created_at: u64,
    297     action: AuthorizationAction,
    298     expiration: u64,
    299     server_domains: Vec<ServerDomain>,
    300     hashes: Vec<Sha256>,
    301 }
    302 
    303 impl AuthorizationClaim {
    304     pub fn parse(content: &str, created_at: u64, tags: &[Vec<String>]) -> Result<Self, Error> {
    305         let content = AuthorizationContent::parse(content)?;
    306         let mut action = None;
    307         let mut expiration = None;
    308         let mut server_domains = Vec::new();
    309         let mut hashes = Vec::new();
    310 
    311         for tag in tags {
    312             match tag.first().map(String::as_str) {
    313                 Some("t") => {
    314                     if action.is_some() {
    315                         return Err(Error::DuplicateAuthorizationActionTag);
    316                     }
    317                     let value = tag.get(1).ok_or(Error::MalformedAuthorizationActionTag)?;
    318                     action = Some(
    319                         AuthorizationAction::parse(value)
    320                             .map_err(|_| Error::MalformedAuthorizationActionTag)?,
    321                     );
    322                 }
    323                 Some("expiration") => {
    324                     if expiration.is_some() {
    325                         return Err(Error::DuplicateAuthorizationExpirationTag);
    326                     }
    327                     let value = tag
    328                         .get(1)
    329                         .ok_or(Error::MalformedAuthorizationExpirationTag)?;
    330                     expiration = Some(parse_timestamp(value)?);
    331                 }
    332                 Some("server") => {
    333                     let value = tag.get(1).ok_or(Error::MalformedAuthorizationServerTag)?;
    334                     server_domains.push(
    335                         ServerDomain::parse(value)
    336                             .map_err(|_| Error::MalformedAuthorizationServerTag)?,
    337                     );
    338                 }
    339                 Some("x") => {
    340                     let value = tag.get(1).ok_or(Error::MalformedAuthorizationHashTag)?;
    341                     hashes.push(
    342                         Sha256::from_hex(value)
    343                             .map_err(|_| Error::MalformedAuthorizationHashTag)?,
    344                     );
    345                 }
    346                 _ => {}
    347             }
    348         }
    349 
    350         Ok(Self {
    351             content,
    352             created_at,
    353             action: action.ok_or(Error::MissingAuthorizationActionTag)?,
    354             expiration: expiration.ok_or(Error::MissingAuthorizationExpirationTag)?,
    355             server_domains,
    356             hashes,
    357         })
    358     }
    359 
    360     pub fn validate(
    361         &self,
    362         validation: &AuthorizationValidation,
    363     ) -> Result<ValidatedAuthorizationClaim, Error> {
    364         if self.created_at >= validation.now {
    365             return Err(Error::AuthorizationCreatedInFuture);
    366         }
    367         if let Some(max_created_age_seconds) = validation.max_created_age_seconds
    368             && validation.now.saturating_sub(self.created_at) > max_created_age_seconds
    369         {
    370             return Err(Error::AuthorizationStale);
    371         }
    372 
    373         let lifetime = self
    374             .expiration
    375             .checked_sub(self.created_at)
    376             .filter(|lifetime| *lifetime > 0)
    377             .ok_or(Error::InvalidAuthorizationLifetime)?;
    378         if let Some(max_lifetime_seconds) = validation.max_lifetime_seconds
    379             && lifetime > max_lifetime_seconds
    380         {
    381             return Err(Error::InvalidAuthorizationLifetime);
    382         }
    383 
    384         if self.expiration <= validation.now {
    385             return Err(Error::AuthorizationExpired);
    386         }
    387         if self.action != validation.target.action() {
    388             return Err(Error::AuthorizationActionMismatch);
    389         }
    390 
    391         self.validate_server_scope(validation)?;
    392         self.validate_hash_scope(validation.target)?;
    393 
    394         Ok(ValidatedAuthorizationClaim {
    395             claim: self.clone(),
    396             target: validation.target,
    397             target_server: validation.target_server.clone(),
    398         })
    399     }
    400 
    401     pub fn content(&self) -> &AuthorizationContent {
    402         &self.content
    403     }
    404 
    405     pub const fn created_at(&self) -> u64 {
    406         self.created_at
    407     }
    408 
    409     pub const fn action(&self) -> AuthorizationAction {
    410         self.action
    411     }
    412 
    413     pub const fn expiration(&self) -> u64 {
    414         self.expiration
    415     }
    416 
    417     pub fn server_domains(&self) -> &[ServerDomain] {
    418         &self.server_domains
    419     }
    420 
    421     pub fn hashes(&self) -> &[Sha256] {
    422         &self.hashes
    423     }
    424 
    425     fn validate_server_scope(&self, validation: &AuthorizationValidation) -> Result<(), Error> {
    426         if self.server_domains.is_empty() {
    427             return match validation.server_scope_requirement {
    428                 ServerScopeRequirement::OptionalAnyMatch => Ok(()),
    429                 ServerScopeRequirement::RequiredAnyMatch => Err(Error::AuthorizationServerRequired),
    430             };
    431         }
    432         if self
    433             .server_domains
    434             .iter()
    435             .any(|server| server == &validation.target_server)
    436         {
    437             Ok(())
    438         } else {
    439             Err(Error::AuthorizationServerMismatch)
    440         }
    441     }
    442 
    443     fn validate_hash_scope(&self, target: AuthorizationTarget) -> Result<(), Error> {
    444         let Some(implied_hash) = target.implied_hash() else {
    445             return Ok(());
    446         };
    447         if self.hashes.is_empty() {
    448             return if target.requires_hash_tag() {
    449                 Err(Error::AuthorizationHashRequired)
    450             } else {
    451                 Ok(())
    452             };
    453         }
    454         if self.hashes.contains(&implied_hash) {
    455             Ok(())
    456         } else {
    457             Err(Error::AuthorizationHashMismatch)
    458         }
    459     }
    460 }
    461 
    462 #[derive(Clone, Debug, PartialEq, Eq)]
    463 pub struct ValidatedAuthorizationClaim {
    464     claim: AuthorizationClaim,
    465     target: AuthorizationTarget,
    466     target_server: ServerDomain,
    467 }
    468 
    469 impl ValidatedAuthorizationClaim {
    470     pub fn claim(&self) -> &AuthorizationClaim {
    471         &self.claim
    472     }
    473 
    474     pub const fn target(&self) -> AuthorizationTarget {
    475         self.target
    476     }
    477 
    478     pub fn target_server(&self) -> &ServerDomain {
    479         &self.target_server
    480     }
    481 
    482     pub fn content(&self) -> &AuthorizationContent {
    483         self.claim.content()
    484     }
    485 
    486     pub const fn created_at(&self) -> u64 {
    487         self.claim.created_at()
    488     }
    489 
    490     pub const fn action(&self) -> AuthorizationAction {
    491         self.claim.action()
    492     }
    493 
    494     pub const fn expiration(&self) -> u64 {
    495         self.claim.expiration()
    496     }
    497 
    498     pub fn server_domains(&self) -> &[ServerDomain] {
    499         self.claim.server_domains()
    500     }
    501 
    502     pub fn hashes(&self) -> &[Sha256] {
    503         self.claim.hashes()
    504     }
    505 }
    506 
    507 #[derive(Clone, Debug, PartialEq, Eq)]
    508 pub struct AuthorizationWireParts {
    509     kind: u16,
    510     created_at: u64,
    511     tags: Vec<Vec<String>>,
    512     content: String,
    513 }
    514 
    515 impl AuthorizationWireParts {
    516     pub const fn kind(&self) -> u16 {
    517         self.kind
    518     }
    519 
    520     pub const fn created_at(&self) -> u64 {
    521         self.created_at
    522     }
    523 
    524     pub fn tags(&self) -> &[Vec<String>] {
    525         &self.tags
    526     }
    527 
    528     pub fn content(&self) -> &str {
    529         &self.content
    530     }
    531 
    532     pub fn into_parts(self) -> (u16, u64, Vec<Vec<String>>, String) {
    533         (self.kind, self.created_at, self.tags, self.content)
    534     }
    535 }
    536 
    537 #[derive(Clone, Debug, PartialEq, Eq)]
    538 pub struct AuthoredUploadClaim {
    539     wire_parts: AuthorizationWireParts,
    540     content: AuthorizationContent,
    541     server_domain: ServerDomain,
    542     sha256: Sha256,
    543     expiration: u64,
    544 }
    545 
    546 impl AuthoredUploadClaim {
    547     pub fn new(
    548         content: AuthorizationContent,
    549         server_domain: ServerDomain,
    550         sha256: Sha256,
    551         created_at: u64,
    552         lifetime_seconds: u64,
    553     ) -> Result<Self, Error> {
    554         if !(1..=RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS).contains(&lifetime_seconds) {
    555             return Err(Error::InvalidAuthorizationLifetime);
    556         }
    557         let expiration = created_at
    558             .checked_add(lifetime_seconds)
    559             .ok_or(Error::AuthorizationTimestampOverflow)?;
    560         let tags = vec![
    561             vec!["t".to_string(), "upload".to_string()],
    562             vec!["expiration".to_string(), expiration.to_string()],
    563             vec!["x".to_string(), sha256.to_hex()],
    564             vec!["server".to_string(), server_domain.as_str().to_string()],
    565         ];
    566         let wire_parts = AuthorizationWireParts {
    567             kind: RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND,
    568             created_at,
    569             tags,
    570             content: content.as_str().to_string(),
    571         };
    572         Ok(Self {
    573             wire_parts,
    574             content,
    575             server_domain,
    576             sha256,
    577             expiration,
    578         })
    579     }
    580 
    581     pub fn content(&self) -> &AuthorizationContent {
    582         &self.content
    583     }
    584 
    585     pub fn server_domain(&self) -> &ServerDomain {
    586         &self.server_domain
    587     }
    588 
    589     pub const fn sha256(&self) -> Sha256 {
    590         self.sha256
    591     }
    592 
    593     pub const fn created_at(&self) -> u64 {
    594         self.wire_parts.created_at
    595     }
    596 
    597     pub const fn expiration(&self) -> u64 {
    598         self.expiration
    599     }
    600 
    601     pub const fn lifetime_seconds(&self) -> u64 {
    602         self.expiration - self.wire_parts.created_at
    603     }
    604 
    605     pub fn wire_parts(&self) -> &AuthorizationWireParts {
    606         &self.wire_parts
    607     }
    608 
    609     pub fn into_wire_parts(self) -> AuthorizationWireParts {
    610         self.wire_parts
    611     }
    612 }
    613 
    614 fn parse_timestamp(value: &str) -> Result<u64, Error> {
    615     if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
    616         return Err(Error::MalformedAuthorizationExpirationTag);
    617     }
    618     value
    619         .parse()
    620         .map_err(|_| Error::MalformedAuthorizationExpirationTag)
    621 }
    622 
    623 fn is_canonical_dotted_ipv4(value: &str) -> bool {
    624     let mut count = 0_usize;
    625     for octet in value.split('.') {
    626         count += 1;
    627         if (octet.len() > 1 && octet.starts_with('0')) || octet.parse::<u8>().is_err() {
    628             return false;
    629         }
    630     }
    631     count == 4
    632 }
    633 
    634 #[cfg(test)]
    635 mod tests {
    636     use super::*;
    637     use alloc::{format, vec};
    638 
    639     const NOW: u64 = 1_800_000_000;
    640     const HASH_HEX: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
    641 
    642     fn hash() -> Sha256 {
    643         Sha256::from_hex(HASH_HEX).unwrap()
    644     }
    645 
    646     fn server() -> ServerDomain {
    647         ServerDomain::parse("media.example.com").unwrap()
    648     }
    649 
    650     fn content() -> AuthorizationContent {
    651         AuthorizationContent::parse("Upload Blob").unwrap()
    652     }
    653 
    654     fn tags(action: &str, expiration: u64) -> Vec<Vec<String>> {
    655         vec![
    656             vec!["t".to_string(), action.to_string()],
    657             vec!["expiration".to_string(), expiration.to_string()],
    658             vec!["x".to_string(), HASH_HEX.to_string()],
    659             vec!["server".to_string(), "media.example.com".to_string()],
    660         ]
    661     }
    662 
    663     fn parse_upload() -> AuthorizationClaim {
    664         AuthorizationClaim::parse("Upload Blob", NOW - 10, &tags("upload", NOW + 60)).unwrap()
    665     }
    666 
    667     fn validation(
    668         target: AuthorizationTarget,
    669         server_requirement: ServerScopeRequirement,
    670     ) -> AuthorizationValidation {
    671         AuthorizationValidation::new(
    672             target,
    673             server(),
    674             server_requirement,
    675             NOW,
    676             RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS,
    677         )
    678         .unwrap()
    679     }
    680 
    681     #[test]
    682     fn actions_are_strict_lowercase_wire_verbs() {
    683         let cases = [
    684             ("get", AuthorizationAction::Get),
    685             ("upload", AuthorizationAction::Upload),
    686             ("list", AuthorizationAction::List),
    687             ("delete", AuthorizationAction::Delete),
    688             ("media", AuthorizationAction::Media),
    689         ];
    690         for (wire, action) in cases {
    691             assert_eq!(AuthorizationAction::parse(wire), Ok(action));
    692             assert_eq!(wire.parse(), Ok(action));
    693             assert_eq!(action.as_str(), wire);
    694             assert_eq!(action.to_string(), wire);
    695         }
    696         assert_eq!(
    697             AuthorizationAction::parse("Upload"),
    698             Err(Error::InvalidAuthorizationAction)
    699         );
    700     }
    701 
    702     #[test]
    703     fn domains_accept_lowercase_ldh_dns_localhost_and_canonical_ip_literals() {
    704         for value in [
    705             "localhost",
    706             "media.example.com",
    707             "xn--bcher-kva.example",
    708             "127.0.0.1",
    709             "0.0.0.0",
    710             "[fd00::1]",
    711             "[2001:db8::1]",
    712         ] {
    713             let domain = ServerDomain::parse(value).unwrap();
    714             assert_eq!(domain.as_str(), value);
    715             assert_eq!(domain.to_string(), value);
    716             assert_eq!(value.parse::<ServerDomain>().unwrap(), domain);
    717         }
    718 
    719         let long_label = format!("{}.example", "a".repeat(64));
    720         let long_domain = "a".repeat(254);
    721         for value in [
    722             "",
    723             "Media.example.com",
    724             "média.example",
    725             "media_example.com",
    726             "-media.example",
    727             "media-.example",
    728             "media..example",
    729             "media.example.",
    730             "https://media.example",
    731             "media.example:443",
    732             "127.1",
    733             "127.00.0.1",
    734             "256.0.0.1",
    735             "2130706433",
    736             "fd00::1",
    737             "[FD00::1]",
    738             "[fd00:0:0:0:0:0:0:1]",
    739             &long_label,
    740             &long_domain,
    741         ] {
    742             assert_eq!(
    743                 ServerDomain::parse(value),
    744                 Err(Error::InvalidAuthorizationServerDomain),
    745                 "{value}"
    746             );
    747         }
    748     }
    749 
    750     #[test]
    751     fn content_requires_bounded_trimmed_human_readable_text() {
    752         for value in ["", " Upload Blob", "Upload Blob ", "Upload\0Blob", "\u{7f}"] {
    753             assert_eq!(
    754                 AuthorizationContent::parse(value),
    755                 Err(Error::InvalidAuthorizationContent)
    756             );
    757         }
    758         assert_eq!(
    759             AuthorizationContent::parse(&"a".repeat(4_097)),
    760             Err(Error::InvalidAuthorizationContent)
    761         );
    762         for value in ["Upload\nBlob", "Upload\tBlob", "Upload\rBlob"] {
    763             assert_eq!(AuthorizationContent::parse(value).unwrap().as_str(), value);
    764         }
    765         let value = AuthorizationContent::parse("Téléverser l'image").unwrap();
    766         assert_eq!(value.as_str(), "Téléverser l'image");
    767         assert_eq!(value.to_string(), "Téléverser l'image");
    768         assert_eq!(
    769             "Upload Blob".parse::<AuthorizationContent>().unwrap(),
    770             content()
    771         );
    772     }
    773 
    774     #[test]
    775     fn targets_derive_protocol_actions_hashes_and_requirements() {
    776         let hash = hash();
    777         let cases = [
    778             (
    779                 AuthorizationTarget::GetBlob(hash),
    780                 AuthorizationAction::Get,
    781                 Some(hash),
    782                 false,
    783             ),
    784             (
    785                 AuthorizationTarget::Upload(hash),
    786                 AuthorizationAction::Upload,
    787                 Some(hash),
    788                 true,
    789             ),
    790             (
    791                 AuthorizationTarget::List,
    792                 AuthorizationAction::List,
    793                 None,
    794                 false,
    795             ),
    796             (
    797                 AuthorizationTarget::DeleteBlob(hash),
    798                 AuthorizationAction::Delete,
    799                 Some(hash),
    800                 true,
    801             ),
    802             (
    803                 AuthorizationTarget::Mirror(hash),
    804                 AuthorizationAction::Upload,
    805                 Some(hash),
    806                 true,
    807             ),
    808             (
    809                 AuthorizationTarget::Media(hash),
    810                 AuthorizationAction::Media,
    811                 Some(hash),
    812                 true,
    813             ),
    814         ];
    815         for (target, action, implied_hash, requires_hash_tag) in cases {
    816             assert_eq!(target.action(), action);
    817             assert_eq!(target.implied_hash(), implied_hash);
    818             assert_eq!(target.requires_hash_tag(), requires_hash_tag);
    819         }
    820     }
    821 
    822     #[test]
    823     fn validation_policy_is_bounded_and_exposes_injected_values() {
    824         let target = AuthorizationTarget::Upload(hash());
    825         let policy = validation(target, ServerScopeRequirement::RequiredAnyMatch);
    826         assert_eq!(policy.target(), target);
    827         assert_eq!(policy.target_server(), &server());
    828         assert_eq!(
    829             policy.server_scope_requirement(),
    830             ServerScopeRequirement::RequiredAnyMatch
    831         );
    832         assert_eq!(policy.now(), NOW);
    833         assert_eq!(policy.max_created_age_seconds(), Some(300));
    834         assert_eq!(policy.max_lifetime_seconds(), Some(300));
    835         assert!(
    836             AuthorizationValidation::new(
    837                 target,
    838                 server(),
    839                 ServerScopeRequirement::OptionalAnyMatch,
    840                 NOW,
    841                 0,
    842             )
    843             .is_ok()
    844         );
    845         assert_eq!(
    846             AuthorizationValidation::new(
    847                 target,
    848                 server(),
    849                 ServerScopeRequirement::OptionalAnyMatch,
    850                 NOW,
    851                 301,
    852             ),
    853             Err(Error::InvalidAuthorizationCreatedAge)
    854         );
    855     }
    856 
    857     #[test]
    858     fn tolerant_parser_accepts_multiple_scopes_trailing_fields_and_unknown_tags() {
    859         let mut tags = tags("upload", NOW + 60);
    860         tags[0].push("ignored".to_string());
    861         tags[1].push("ignored".to_string());
    862         tags[2].push("ignored".to_string());
    863         tags[3].push("ignored".to_string());
    864         tags.push(vec!["server".to_string(), "other.example".to_string()]);
    865         tags.push(vec!["x".to_string(), Sha256::digest(b"other").to_hex()]);
    866         tags.push(vec!["unknown".to_string()]);
    867         tags.push(Vec::new());
    868 
    869         let parsed = AuthorizationClaim::parse("Upload Blob", NOW - 10, &tags).unwrap();
    870         assert_eq!(parsed.content(), &content());
    871         assert_eq!(parsed.created_at(), NOW - 10);
    872         assert_eq!(parsed.action(), AuthorizationAction::Upload);
    873         assert_eq!(parsed.expiration(), NOW + 60);
    874         assert_eq!(parsed.server_domains().len(), 2);
    875         assert_eq!(parsed.hashes().len(), 2);
    876     }
    877 
    878     #[test]
    879     fn tolerant_parser_rejects_missing_duplicate_and_malformed_known_tags() {
    880         let cases = [
    881             (
    882                 vec![vec!["expiration".to_string(), (NOW + 60).to_string()]],
    883                 Error::MissingAuthorizationActionTag,
    884             ),
    885             (
    886                 vec![vec!["t".to_string(), "upload".to_string()]],
    887                 Error::MissingAuthorizationExpirationTag,
    888             ),
    889             (
    890                 vec![
    891                     vec!["t".to_string(), "upload".to_string()],
    892                     vec!["t".to_string(), "upload".to_string()],
    893                     vec!["expiration".to_string(), (NOW + 60).to_string()],
    894                 ],
    895                 Error::DuplicateAuthorizationActionTag,
    896             ),
    897             (
    898                 vec![
    899                     vec!["t".to_string(), "upload".to_string()],
    900                     vec!["expiration".to_string(), (NOW + 60).to_string()],
    901                     vec!["expiration".to_string(), (NOW + 60).to_string()],
    902                 ],
    903                 Error::DuplicateAuthorizationExpirationTag,
    904             ),
    905             (
    906                 vec![
    907                     vec!["t".to_string()],
    908                     vec!["expiration".to_string(), (NOW + 60).to_string()],
    909                 ],
    910                 Error::MalformedAuthorizationActionTag,
    911             ),
    912             (
    913                 vec![
    914                     vec!["t".to_string(), "upload".to_string()],
    915                     vec!["expiration".to_string()],
    916                 ],
    917                 Error::MalformedAuthorizationExpirationTag,
    918             ),
    919             (
    920                 vec![
    921                     vec!["t".to_string(), "upload".to_string()],
    922                     vec!["expiration".to_string(), (NOW + 60).to_string()],
    923                     vec!["server".to_string()],
    924                 ],
    925                 Error::MalformedAuthorizationServerTag,
    926             ),
    927             (
    928                 vec![
    929                     vec!["t".to_string(), "upload".to_string()],
    930                     vec!["expiration".to_string(), (NOW + 60).to_string()],
    931                     vec!["x".to_string()],
    932                 ],
    933                 Error::MalformedAuthorizationHashTag,
    934             ),
    935         ];
    936         for (tags, expected) in cases {
    937             assert_eq!(
    938                 AuthorizationClaim::parse("Upload Blob", NOW, &tags),
    939                 Err(expected)
    940             );
    941         }
    942 
    943         for expiration in ["", " 10", "+10", "ten", "18446744073709551616"] {
    944             let tags = vec![
    945                 vec!["t".to_string(), "upload".to_string()],
    946                 vec!["expiration".to_string(), expiration.to_string()],
    947             ];
    948             assert_eq!(
    949                 AuthorizationClaim::parse("Upload Blob", NOW, &tags),
    950                 Err(Error::MalformedAuthorizationExpirationTag)
    951             );
    952         }
    953 
    954         let mut invalid = tags("Upload", NOW + 60);
    955         assert_eq!(
    956             AuthorizationClaim::parse("Upload Blob", NOW, &invalid),
    957             Err(Error::MalformedAuthorizationActionTag)
    958         );
    959         invalid = tags("upload", NOW + 60);
    960         invalid[3][1] = "Media.example".to_string();
    961         assert_eq!(
    962             AuthorizationClaim::parse("Upload Blob", NOW, &invalid),
    963             Err(Error::MalformedAuthorizationServerTag)
    964         );
    965         invalid = tags("upload", NOW + 60);
    966         invalid[2][1] = "not-a-hash".to_string();
    967         assert_eq!(
    968             AuthorizationClaim::parse("Upload Blob", NOW, &invalid),
    969             Err(Error::MalformedAuthorizationHashTag)
    970         );
    971     }
    972 
    973     #[test]
    974     fn upload_validation_enforces_time_action_and_required_scopes() {
    975         let target = AuthorizationTarget::Upload(hash());
    976         let required = validation(target, ServerScopeRequirement::RequiredAnyMatch);
    977         let validated = parse_upload().validate(&required).unwrap();
    978         assert_eq!(validated.claim(), &parse_upload());
    979         assert_eq!(validated.target(), target);
    980         assert_eq!(validated.target_server(), &server());
    981         assert_eq!(validated.content(), &content());
    982         assert_eq!(validated.created_at(), NOW - 10);
    983         assert_eq!(validated.action(), AuthorizationAction::Upload);
    984         assert_eq!(validated.expiration(), NOW + 60);
    985         assert_eq!(validated.server_domains(), &[server()]);
    986         assert_eq!(validated.hashes(), &[hash()]);
    987 
    988         let time_cases = [
    989             (NOW, NOW + 60, Error::AuthorizationCreatedInFuture),
    990             (NOW + 1, NOW + 60, Error::AuthorizationCreatedInFuture),
    991             (NOW - 301, NOW - 1, Error::AuthorizationStale),
    992             (NOW - 10, NOW - 10, Error::InvalidAuthorizationLifetime),
    993             (NOW - 10, NOW + 291, Error::InvalidAuthorizationLifetime),
    994             (NOW - 10, NOW, Error::AuthorizationExpired),
    995         ];
    996         for (created_at, expiration, expected) in time_cases {
    997             let parsed =
    998                 AuthorizationClaim::parse("Upload Blob", created_at, &tags("upload", expiration))
    999                     .unwrap();
   1000             assert_eq!(parsed.validate(&required), Err(expected));
   1001         }
   1002 
   1003         let wrong_action =
   1004             AuthorizationClaim::parse("List Blobs", NOW - 10, &tags("list", NOW + 60)).unwrap();
   1005         assert_eq!(
   1006             wrong_action.validate(&required),
   1007             Err(Error::AuthorizationActionMismatch)
   1008         );
   1009     }
   1010 
   1011     #[test]
   1012     fn bud11_validation_keeps_radroots_replay_limits_out_of_protocol_semantics() {
   1013         let target = AuthorizationTarget::List;
   1014         let policy = AuthorizationValidation::bud11(target, server(), NOW);
   1015         assert_eq!(policy.max_created_age_seconds(), None);
   1016         assert_eq!(policy.max_lifetime_seconds(), None);
   1017         assert_eq!(
   1018             policy.server_scope_requirement(),
   1019             ServerScopeRequirement::OptionalAnyMatch
   1020         );
   1021 
   1022         let claim = AuthorizationClaim::parse(
   1023             "List archived blobs",
   1024             NOW - 1_000,
   1025             &[
   1026                 vec!["t".to_string(), "list".to_string()],
   1027                 vec!["expiration".to_string(), (NOW + 1_000).to_string()],
   1028             ],
   1029         )
   1030         .unwrap();
   1031         assert!(claim.validate(&policy).is_ok());
   1032     }
   1033 
   1034     #[test]
   1035     fn server_scope_uses_optional_or_required_any_match() {
   1036         let target = AuthorizationTarget::Upload(hash());
   1037         let optional = validation(target, ServerScopeRequirement::OptionalAnyMatch);
   1038         let required = validation(target, ServerScopeRequirement::RequiredAnyMatch);
   1039 
   1040         let no_server =
   1041             AuthorizationClaim::parse("Upload Blob", NOW - 10, &tags("upload", NOW + 60)[..3])
   1042                 .unwrap();
   1043         assert!(no_server.validate(&optional).is_ok());
   1044         assert_eq!(
   1045             no_server.validate(&required),
   1046             Err(Error::AuthorizationServerRequired)
   1047         );
   1048 
   1049         let mut mismatched_tags = tags("upload", NOW + 60);
   1050         mismatched_tags[3][1] = "other.example".to_string();
   1051         let mismatch =
   1052             AuthorizationClaim::parse("Upload Blob", NOW - 10, &mismatched_tags).unwrap();
   1053         assert_eq!(
   1054             mismatch.validate(&optional),
   1055             Err(Error::AuthorizationServerMismatch)
   1056         );
   1057 
   1058         mismatched_tags.push(vec!["server".to_string(), "media.example.com".to_string()]);
   1059         let any_match =
   1060             AuthorizationClaim::parse("Upload Blob", NOW - 10, &mismatched_tags).unwrap();
   1061         assert!(any_match.validate(&required).is_ok());
   1062     }
   1063 
   1064     #[test]
   1065     fn hash_scope_follows_each_endpoint_requirement_with_any_match() {
   1066         let hash = hash();
   1067         let no_scope = vec![
   1068             vec!["t".to_string(), "get".to_string()],
   1069             vec!["expiration".to_string(), (NOW + 60).to_string()],
   1070         ];
   1071         let get = AuthorizationClaim::parse("Get Blob", NOW - 10, &no_scope).unwrap();
   1072         assert!(
   1073             get.validate(&validation(
   1074                 AuthorizationTarget::GetBlob(hash),
   1075                 ServerScopeRequirement::OptionalAnyMatch,
   1076             ))
   1077             .is_ok()
   1078         );
   1079 
   1080         let list = AuthorizationClaim::parse(
   1081             "List Blobs",
   1082             NOW - 10,
   1083             &[
   1084                 vec!["t".to_string(), "list".to_string()],
   1085                 vec!["expiration".to_string(), (NOW + 60).to_string()],
   1086                 vec!["x".to_string(), HASH_HEX.to_string()],
   1087             ],
   1088         )
   1089         .unwrap();
   1090         assert!(
   1091             list.validate(&validation(
   1092                 AuthorizationTarget::List,
   1093                 ServerScopeRequirement::OptionalAnyMatch,
   1094             ))
   1095             .is_ok()
   1096         );
   1097 
   1098         let missing_required = AuthorizationClaim::parse(
   1099             "Upload Blob",
   1100             NOW - 10,
   1101             &[
   1102                 vec!["t".to_string(), "upload".to_string()],
   1103                 vec!["expiration".to_string(), (NOW + 60).to_string()],
   1104             ],
   1105         )
   1106         .unwrap();
   1107         assert_eq!(
   1108             missing_required.validate(&validation(
   1109                 AuthorizationTarget::Upload(hash),
   1110                 ServerScopeRequirement::OptionalAnyMatch,
   1111             )),
   1112             Err(Error::AuthorizationHashRequired)
   1113         );
   1114 
   1115         let wrong_hash = Sha256::digest(b"wrong");
   1116         let wrong_scope = AuthorizationClaim::parse(
   1117             "Get Blob",
   1118             NOW - 10,
   1119             &[
   1120                 vec!["t".to_string(), "get".to_string()],
   1121                 vec!["expiration".to_string(), (NOW + 60).to_string()],
   1122                 vec!["x".to_string(), wrong_hash.to_hex()],
   1123             ],
   1124         )
   1125         .unwrap();
   1126         assert_eq!(
   1127             wrong_scope.validate(&validation(
   1128                 AuthorizationTarget::GetBlob(hash),
   1129                 ServerScopeRequirement::OptionalAnyMatch,
   1130             )),
   1131             Err(Error::AuthorizationHashMismatch)
   1132         );
   1133 
   1134         let mut any_tags = tags("delete", NOW + 60);
   1135         any_tags[2][1] = wrong_hash.to_hex();
   1136         any_tags.push(vec!["x".to_string(), HASH_HEX.to_string()]);
   1137         let any_match = AuthorizationClaim::parse("Delete Blob", NOW - 10, &any_tags).unwrap();
   1138         for target in [
   1139             AuthorizationTarget::DeleteBlob(hash),
   1140             AuthorizationTarget::Mirror(hash),
   1141             AuthorizationTarget::Media(hash),
   1142         ] {
   1143             let action = target.action();
   1144             let mut action_tags = any_tags.clone();
   1145             action_tags[0][1] = action.as_str().to_string();
   1146             let parsed =
   1147                 AuthorizationClaim::parse("Authorized Operation", NOW - 10, &action_tags).unwrap();
   1148             assert!(
   1149                 parsed
   1150                     .validate(&validation(
   1151                         target,
   1152                         ServerScopeRequirement::RequiredAnyMatch,
   1153                     ))
   1154                     .is_ok()
   1155             );
   1156         }
   1157         assert_eq!(any_match.hashes().len(), 2);
   1158     }
   1159 
   1160     #[test]
   1161     fn strict_authored_upload_claim_emits_canonical_wire_parts() {
   1162         let claim = AuthoredUploadClaim::new(content(), server(), hash(), NOW, 60).unwrap();
   1163         assert_eq!(claim.content(), &content());
   1164         assert_eq!(claim.server_domain(), &server());
   1165         assert_eq!(claim.sha256(), hash());
   1166         assert_eq!(claim.created_at(), NOW);
   1167         assert_eq!(claim.expiration(), NOW + 60);
   1168         assert_eq!(claim.lifetime_seconds(), 60);
   1169 
   1170         let wire = claim.wire_parts();
   1171         assert_eq!(wire.kind(), RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND);
   1172         assert_eq!(wire.created_at(), NOW);
   1173         assert_eq!(wire.content(), "Upload Blob");
   1174         assert_eq!(wire.tags(), tags("upload", NOW + 60));
   1175 
   1176         let (kind, created_at, tags, content) = claim.into_wire_parts().into_parts();
   1177         assert_eq!(kind, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND);
   1178         assert_eq!(created_at, NOW);
   1179         assert_eq!(tags, self::tags("upload", NOW + 60));
   1180         assert_eq!(content, "Upload Blob");
   1181     }
   1182 
   1183     #[test]
   1184     fn strict_authored_upload_claim_rejects_bad_lifetime_and_overflow() {
   1185         for lifetime in [0, 301] {
   1186             assert_eq!(
   1187                 AuthoredUploadClaim::new(content(), server(), hash(), NOW, lifetime,),
   1188                 Err(Error::InvalidAuthorizationLifetime)
   1189             );
   1190         }
   1191         assert_eq!(
   1192             AuthoredUploadClaim::new(content(), server(), hash(), u64::MAX, 1,),
   1193             Err(Error::AuthorizationTimestampOverflow)
   1194         );
   1195     }
   1196 
   1197     #[test]
   1198     fn validation_enforces_exact_time_boundaries() {
   1199         let boundary_tags = tags("upload", NOW + 1);
   1200         let parsed = AuthorizationClaim::parse(
   1201             "Upload Blob",
   1202             NOW - (RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS - 1),
   1203             &boundary_tags,
   1204         )
   1205         .unwrap();
   1206         assert!(
   1207             parsed
   1208                 .validate(&validation(
   1209                     AuthorizationTarget::Upload(hash()),
   1210                     ServerScopeRequirement::RequiredAnyMatch,
   1211                 ))
   1212                 .is_ok()
   1213         );
   1214 
   1215         let zero_age = AuthorizationValidation::new(
   1216             AuthorizationTarget::Upload(hash()),
   1217             server(),
   1218             ServerScopeRequirement::RequiredAnyMatch,
   1219             NOW,
   1220             0,
   1221         )
   1222         .unwrap();
   1223         let created_now = AuthorizationClaim::parse(
   1224             "Upload Blob",
   1225             NOW,
   1226             &tags("upload", NOW + RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS),
   1227         )
   1228         .unwrap();
   1229         assert_eq!(
   1230             created_now.validate(&zero_age),
   1231             Err(Error::AuthorizationCreatedInFuture)
   1232         );
   1233     }
   1234 }