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 }