blossom_conformance.rs (22520B)
1 #![cfg(feature = "blossom")] 2 3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 4 use nostr::Keys as RadrootsNostrKeys; 5 use radroots_blossom::{ 6 Sha256, 7 authorization::{ 8 AuthoredUploadClaim, AuthorizationClaim, AuthorizationContent, AuthorizationTarget, 9 AuthorizationValidation, RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, 10 RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, 11 ServerDomain, ServerScopeRequirement, 12 }, 13 }; 14 use radroots_event::{SignedEvent, wire::Nip01EventWire}; 15 use radroots_nostr::blossom::{ 16 AuthorizationError, SignedAuthorization, decode_verify_authorization_header, 17 encode_authorization_header, encode_signed_event_authorization_header, sign_authorization, 18 }; 19 use radroots_nostr::{ 20 event::{ 21 Event as RadrootsNostrEvent, Kind as RadrootsNostrKind, Timestamp as RadrootsNostrTimestamp, 22 }, 23 tag::Tag as RadrootsNostrTag, 24 }; 25 use serde::Deserialize; 26 use serde_json::Value; 27 use std::{borrow::Cow, fs, path::Path}; 28 29 const PACKAGED_VECTORS: &str = include_str!("fixtures/bud11_nostr_adapter.v1.json"); 30 const WORKSPACE_VECTOR_PATH: &str = 31 "../../contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json"; 32 const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; 33 34 const SECRET_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; 35 const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; 36 const SERVER: &str = "cdn.example.com"; 37 const CONTENT: &str = "Upload Victoria farm photo"; 38 const CREATED_AT: u64 = 1_700_000_000; 39 const LIFETIME: u64 = RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS; 40 41 #[derive(Debug, Deserialize)] 42 struct Suite { 43 suite: String, 44 contract_version: String, 45 vectors: Vec<Vector>, 46 } 47 48 #[derive(Debug, Deserialize)] 49 struct Vector { 50 id: String, 51 kind: String, 52 input: Value, 53 expected: Value, 54 } 55 56 #[test] 57 fn checked_in_blossom_bud11_nostr_vectors_execute_against_public_api() { 58 let vectors = conformance_vectors(); 59 let suite: Suite = serde_json::from_str(&vectors).expect("BUD-11 Nostr vectors must parse"); 60 assert_eq!(suite.suite, "blossom_bud11_nostr_adapter"); 61 assert_eq!(suite.contract_version, "1.0.0"); 62 assert!(!suite.vectors.is_empty()); 63 64 for vector in &suite.vectors { 65 execute_vector(vector); 66 } 67 } 68 69 fn conformance_vectors() -> Cow<'static, str> { 70 let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); 71 match fs::read_to_string(&workspace_path) { 72 Ok(canonical) => { 73 assert_eq!( 74 canonical, 75 PACKAGED_VECTORS, 76 "packaged BUD-11 Nostr vectors must match {}", 77 workspace_path.display() 78 ); 79 Cow::Owned(canonical) 80 } 81 Err(error) 82 if error.kind() == std::io::ErrorKind::NotFound 83 && !Path::new(env!("CARGO_MANIFEST_DIR")) 84 .join(WORKSPACE_CONTRACT_MARKER_PATH) 85 .is_file() => 86 { 87 Cow::Borrowed(PACKAGED_VECTORS) 88 } 89 Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), 90 } 91 } 92 93 fn execute_vector(vector: &Vector) { 94 assert_embedded_event_matches_header(vector); 95 let validation = vector_validation(vector); 96 match vector.kind.as_str() { 97 "blossom.bud11.nostr.decode_verify.valid" => { 98 let verified = 99 decode_verify_authorization_header(input_str(vector, "header"), &validation) 100 .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); 101 assert_eq!( 102 verified.event_id().to_string(), 103 expected_str(vector, "event_id"), 104 "{}", 105 vector.id 106 ); 107 assert_eq!( 108 verified.author().to_string(), 109 expected_str(vector, "pubkey"), 110 "{}", 111 vector.id 112 ); 113 assert_eq!( 114 verified.claim().action().as_str(), 115 expected_str(vector, "action"), 116 "{}", 117 vector.id 118 ); 119 assert_eq!( 120 verified.claim().content().as_str(), 121 expected_str(vector, "content"), 122 "{}", 123 vector.id 124 ); 125 assert_eq!( 126 verified.claim().created_at(), 127 expected_u64(vector, "created_at"), 128 "{}", 129 vector.id 130 ); 131 assert_eq!( 132 verified.claim().expiration(), 133 expected_u64(vector, "expiration"), 134 "{}", 135 vector.id 136 ); 137 assert_eq!( 138 verified 139 .claim() 140 .hashes() 141 .iter() 142 .map(ToString::to_string) 143 .collect::<Vec<_>>(), 144 expected_strings(vector, "hashes"), 145 "{}", 146 vector.id 147 ); 148 assert_eq!( 149 verified 150 .claim() 151 .server_domains() 152 .iter() 153 .map(ToString::to_string) 154 .collect::<Vec<_>>(), 155 expected_strings(vector, "servers"), 156 "{}", 157 vector.id 158 ); 159 } 160 "blossom.bud11.nostr.decode_verify.invalid" => { 161 let error = 162 decode_verify_authorization_header(input_str(vector, "header"), &validation) 163 .expect_err("invalid BUD-11 Nostr vector must fail"); 164 assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); 165 if let Some(actual_kind) = vector.expected.get("actual_kind") { 166 assert_eq!( 167 error, 168 AuthorizationError::InvalidEventKind { 169 actual: actual_kind.as_u64().expect("actual_kind must be u64"), 170 }, 171 "{}", 172 vector.id 173 ); 174 } 175 } 176 kind => panic!("{} uses unsupported vector kind {kind}", vector.id), 177 } 178 } 179 180 fn assert_embedded_event_matches_header(vector: &Vector) { 181 let Some(event_json) = vector.input.get("event_json").and_then(Value::as_str) else { 182 return; 183 }; 184 let payload = header_payload(input_str(vector, "header")); 185 let decoded = URL_SAFE_NO_PAD 186 .decode(payload) 187 .unwrap_or_else(|error| panic!("{} header failed Base64url decode: {error}", vector.id)); 188 assert_eq!(decoded, event_json.as_bytes(), "{}", vector.id); 189 } 190 191 fn header_payload(header: &str) -> &str { 192 let first_space = header 193 .as_bytes() 194 .iter() 195 .position(|byte| *byte == b' ') 196 .expect("authorization header requires SP"); 197 assert!( 198 header.as_bytes()[..first_space].eq_ignore_ascii_case(b"Nostr"), 199 "authorization header requires Nostr scheme" 200 ); 201 let payload_start = header.as_bytes()[first_space..] 202 .iter() 203 .position(|byte| *byte != b' ') 204 .map_or(header.len(), |offset| first_space + offset); 205 &header[payload_start..] 206 } 207 208 fn vector_validation(vector: &Vector) -> AuthorizationValidation { 209 let validation = vector.input.get("validation").expect("validation input"); 210 let target = validation.get("target").expect("validation target"); 211 let target_type = target 212 .get("type") 213 .and_then(Value::as_str) 214 .expect("validation target type"); 215 let target_hash = || { 216 Sha256::from_hex( 217 target 218 .get("hash") 219 .and_then(Value::as_str) 220 .expect("validation target hash"), 221 ) 222 .expect("valid validation target hash") 223 }; 224 let target = match target_type { 225 "get_blob" => AuthorizationTarget::GetBlob(target_hash()), 226 "upload" => AuthorizationTarget::Upload(target_hash()), 227 "list" => AuthorizationTarget::List, 228 "delete_blob" => AuthorizationTarget::DeleteBlob(target_hash()), 229 "mirror" => AuthorizationTarget::Mirror(target_hash()), 230 "media" => AuthorizationTarget::Media(target_hash()), 231 other => panic!("{} has unsupported target {other}", vector.id), 232 }; 233 let server = ServerDomain::parse( 234 validation 235 .get("server_domain") 236 .and_then(Value::as_str) 237 .expect("validation server_domain"), 238 ) 239 .expect("valid validation server_domain"); 240 let server_scope = match validation 241 .get("server_scope") 242 .and_then(Value::as_str) 243 .expect("validation server_scope") 244 { 245 "optional_any_match" => ServerScopeRequirement::OptionalAnyMatch, 246 "required_any_match" => ServerScopeRequirement::RequiredAnyMatch, 247 other => panic!("{} has unsupported server scope {other}", vector.id), 248 }; 249 AuthorizationValidation::new( 250 target, 251 server, 252 server_scope, 253 validation 254 .get("now") 255 .and_then(Value::as_u64) 256 .expect("validation now"), 257 validation 258 .get("max_created_age") 259 .and_then(Value::as_u64) 260 .expect("validation max_created_age"), 261 ) 262 .expect("valid vector validation") 263 } 264 265 fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str { 266 vector 267 .input 268 .get(field) 269 .and_then(Value::as_str) 270 .unwrap_or_else(|| panic!("{} missing string input {field}", vector.id)) 271 } 272 273 fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str { 274 vector 275 .expected 276 .get(field) 277 .and_then(Value::as_str) 278 .unwrap_or_else(|| panic!("{} missing string expectation {field}", vector.id)) 279 } 280 281 fn expected_u64(vector: &Vector, field: &str) -> u64 { 282 vector 283 .expected 284 .get(field) 285 .and_then(Value::as_u64) 286 .unwrap_or_else(|| panic!("{} missing u64 expectation {field}", vector.id)) 287 } 288 289 fn expected_strings(vector: &Vector, field: &str) -> Vec<String> { 290 vector 291 .expected 292 .get(field) 293 .and_then(Value::as_array) 294 .unwrap_or_else(|| panic!("{} missing array expectation {field}", vector.id)) 295 .iter() 296 .map(|value| { 297 value 298 .as_str() 299 .unwrap_or_else(|| panic!("{} has non-string {field} value", vector.id)) 300 .to_owned() 301 }) 302 .collect() 303 } 304 305 fn keys() -> RadrootsNostrKeys { 306 RadrootsNostrKeys::parse(SECRET_KEY).expect("fixed test secret key") 307 } 308 309 fn hash() -> Sha256 { 310 Sha256::from_hex(HASH).expect("fixed test hash") 311 } 312 313 fn server() -> ServerDomain { 314 ServerDomain::parse(SERVER).expect("fixed test server") 315 } 316 317 fn authored_claim() -> AuthoredUploadClaim { 318 AuthoredUploadClaim::new( 319 AuthorizationContent::parse(CONTENT).expect("fixed test content"), 320 server(), 321 hash(), 322 CREATED_AT, 323 LIFETIME, 324 ) 325 .expect("fixed authored claim") 326 } 327 328 fn validation() -> AuthorizationValidation { 329 AuthorizationValidation::new( 330 AuthorizationTarget::Upload(hash()), 331 server(), 332 ServerScopeRequirement::RequiredAnyMatch, 333 CREATED_AT + 1, 334 RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, 335 ) 336 .expect("fixed validation policy") 337 } 338 339 fn raw_header(event: &RadrootsNostrEvent) -> String { 340 let json = serde_json::to_vec(event).expect("test event JSON"); 341 raw_json_header(&String::from_utf8(json).expect("test event JSON is UTF-8")) 342 } 343 344 fn raw_json_header(json: &str) -> String { 345 format!("Nostr {}", URL_SAFE_NO_PAD.encode(json)) 346 } 347 348 fn event_from_header(header: &str) -> RadrootsNostrEvent { 349 let json = URL_SAFE_NO_PAD 350 .decode(header_payload(header)) 351 .expect("test authorization payload"); 352 serde_json::from_slice(&json).expect("test authorization event") 353 } 354 355 fn sign_raw(kind: u16, content: &str, tags: Vec<RadrootsNostrTag>) -> RadrootsNostrEvent { 356 nostr::EventBuilder::new(RadrootsNostrKind::Custom(kind), content) 357 .tags(tags) 358 .custom_created_at(RadrootsNostrTimestamp::from_secs(CREATED_AT)) 359 .sign_with_keys(&keys()) 360 .expect("raw test event signs") 361 } 362 363 fn exact_signed_event(event: &RadrootsNostrEvent) -> SignedEvent { 364 let raw_json = serde_json::to_string(event).expect("test event JSON"); 365 let wire = Nip01EventWire::parse_json(&raw_json).expect("test event wire"); 366 SignedEvent::from_wire_verified_id(wire, raw_json).expect("test event has a verified ID") 367 } 368 369 #[test] 370 fn blossom_authored_claim_signs_and_roundtrips_without_signature_assumptions() { 371 let claim = authored_claim(); 372 let signed = sign_authorization(&keys(), &claim).expect("sign authored authorization"); 373 let wire = claim.wire_parts(); 374 375 let header = encode_authorization_header(&signed); 376 let event = event_from_header(header.as_str()); 377 378 assert_eq!(event.kind.as_u16(), wire.kind()); 379 assert_eq!(event.created_at.as_secs(), wire.created_at()); 380 assert_eq!(event.content, wire.content()); 381 assert_eq!( 382 event 383 .tags 384 .iter() 385 .map(|tag| tag.as_slice().to_vec()) 386 .collect::<Vec<_>>(), 387 wire.tags() 388 ); 389 assert!(event.verify_id()); 390 assert!(event.verify_signature()); 391 assert_eq!(signed.event_id(), event.id); 392 assert_eq!(signed.author(), event.pubkey); 393 assert_eq!(signed.created_at(), event.created_at); 394 395 assert!(header.as_str().starts_with("Nostr ")); 396 assert!(!header.as_str().contains('=')); 397 assert_eq!(header.as_ref(), header.as_str()); 398 assert!(!format!("{header:?}").contains(header.as_str())); 399 assert!(!format!("{signed:?}").contains(&event.sig.to_string())); 400 401 let verified = 402 decode_verify_authorization_header(header.as_str(), &validation()).expect("verify header"); 403 assert_eq!(verified.event_id(), event.id); 404 assert_eq!(verified.author(), keys().public_key()); 405 assert_eq!(verified.created_at(), event.created_at); 406 assert_eq!(verified.claim().content().to_string(), CONTENT); 407 assert_eq!(verified.claim().created_at(), CREATED_AT); 408 assert_eq!(verified.claim().action().to_string(), "upload"); 409 assert_eq!(verified.claim().expiration(), CREATED_AT + LIFETIME); 410 assert_eq!(verified.claim().server_domains(), &[server()]); 411 assert_eq!(verified.claim().hashes(), &[hash()]); 412 assert!(!format!("{verified:?}").contains(&event.sig.to_string())); 413 } 414 415 #[test] 416 fn opaque_signed_event_conversion_rechecks_kind_and_signature() { 417 let signed = sign_authorization(&keys(), &authored_claim()).expect("sign authorization"); 418 let expected_header = encode_authorization_header(&signed); 419 let event = event_from_header(expected_header.as_str()); 420 let exact_event = exact_signed_event(&event); 421 422 let converted = 423 SignedAuthorization::from_signed_event(&exact_event).expect("convert exact BUD-11 event"); 424 assert_eq!(converted.event_id(), event.id); 425 assert_eq!(converted.author(), event.pubkey); 426 assert_eq!(converted.created_at(), event.created_at); 427 assert_eq!( 428 encode_signed_event_authorization_header(&exact_event).unwrap(), 429 expected_header 430 ); 431 432 let wrong_kind = exact_signed_event(&sign_raw(1, "", Vec::new())); 433 assert_eq!( 434 SignedAuthorization::from_signed_event(&wrong_kind), 435 Err(AuthorizationError::InvalidEventKind { actual: 1 }) 436 ); 437 438 let mut invalid_signature = event; 439 invalid_signature.sig = sign_raw( 440 RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, 441 "different", 442 Vec::new(), 443 ) 444 .sig; 445 assert!(invalid_signature.verify_id()); 446 assert!(!invalid_signature.verify_signature()); 447 assert_eq!( 448 SignedAuthorization::from_signed_event(&exact_signed_event(&invalid_signature)), 449 Err(AuthorizationError::InvalidEventSignature) 450 ); 451 } 452 453 #[test] 454 fn blossom_header_rejects_noncanonical_and_malformed_encodings() { 455 let signed = 456 sign_authorization(&keys(), &authored_claim()).expect("sign authored authorization"); 457 let valid = encode_authorization_header(&signed).into_string(); 458 let policy = validation(); 459 460 for accepted in [ 461 valid.replacen("Nostr ", "nostr ", 1), 462 valid.replacen("Nostr ", "NOSTR ", 1), 463 ] { 464 decode_verify_authorization_header(&accepted, &policy) 465 .expect("auth-scheme is case-insensitive and accepts 1*SP"); 466 } 467 468 let cases = [ 469 ( 470 format!(" {valid}"), 471 AuthorizationError::InvalidHeaderWhitespace, 472 ), 473 ( 474 "Nostr e 30".to_owned(), 475 AuthorizationError::InvalidHeaderWhitespace, 476 ), 477 ("Nostr".to_owned(), AuthorizationError::InvalidHeaderScheme), 478 ("Nostr ".to_owned(), AuthorizationError::EmptyHeaderPayload), 479 ( 480 format!("{valid}="), 481 AuthorizationError::HeaderPaddingForbidden, 482 ), 483 ( 484 "Nostr +".to_owned(), 485 AuthorizationError::InvalidHeaderBase64, 486 ), 487 ( 488 "Nostr Zh".to_owned(), 489 AuthorizationError::NonCanonicalHeaderBase64, 490 ), 491 ("Nostr _w".to_owned(), AuthorizationError::InvalidHeaderUtf8), 492 ("Nostr e30".to_owned(), AuthorizationError::InvalidEventJson), 493 ]; 494 495 for (header, expected) in cases { 496 assert_eq!( 497 decode_verify_authorization_header(&header, &policy), 498 Err(expected), 499 "{header}" 500 ); 501 } 502 } 503 504 #[test] 505 fn blossom_header_rejects_kind_narrowing_and_json_shape_laundering() { 506 let signed = 507 sign_authorization(&keys(), &authored_claim()).expect("sign authored authorization"); 508 let encoded = encode_authorization_header(&signed); 509 let event_json = 510 serde_json::to_string(&event_from_header(encoded.as_str())).expect("event JSON"); 511 let policy = validation(); 512 513 let cases = [ 514 ( 515 event_json.replacen("\"kind\":24242", "\"kind\":89778", 1), 516 AuthorizationError::InvalidEventKind { actual: 89_778 }, 517 ), 518 ( 519 format!( 520 "{},\"unknown\":true}}", 521 event_json.strip_suffix('}').unwrap() 522 ), 523 AuthorizationError::InvalidEventJson, 524 ), 525 ( 526 event_json.replacen("\"content\":", "\"unknown\":", 1), 527 AuthorizationError::InvalidEventJson, 528 ), 529 ( 530 event_json.replacen("\"kind\":24242", "\"kind\":24242,\"kind\":24242", 1), 531 AuthorizationError::InvalidEventJson, 532 ), 533 ( 534 event_json.replacen("\"kind\":24242", "\"kind\":\"24242\"", 1), 535 AuthorizationError::InvalidEventJson, 536 ), 537 ("[]".to_owned(), AuthorizationError::InvalidEventJson), 538 ("{".to_owned(), AuthorizationError::InvalidEventJson), 539 ]; 540 541 for (json, expected) in cases { 542 assert_eq!( 543 decode_verify_authorization_header(&raw_json_header(&json), &policy,), 544 Err(expected), 545 "{json}" 546 ); 547 } 548 } 549 550 #[test] 551 fn blossom_header_authenticates_before_claim_parsing() { 552 let policy = validation(); 553 554 let wrong_kind = sign_raw(1, "", Vec::new()); 555 assert_eq!( 556 decode_verify_authorization_header(&raw_header(&wrong_kind), &policy), 557 Err(AuthorizationError::InvalidEventKind { actual: 1 }) 558 ); 559 560 let malformed_claim = sign_raw(RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, "", Vec::new()); 561 let mut bad_id = malformed_claim.clone(); 562 bad_id.content.push('x'); 563 assert_eq!( 564 decode_verify_authorization_header(&raw_header(&bad_id), &policy), 565 Err(AuthorizationError::InvalidEventId) 566 ); 567 568 let mut bad_signature = malformed_claim.clone(); 569 bad_signature.sig = sign_raw( 570 RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, 571 "different", 572 Vec::new(), 573 ) 574 .sig; 575 assert!(bad_signature.verify_id()); 576 assert!(!bad_signature.verify_signature()); 577 assert_eq!( 578 decode_verify_authorization_header(&raw_header(&bad_signature), &policy), 579 Err(AuthorizationError::InvalidEventSignature) 580 ); 581 582 let raw_tags: Vec<Vec<String>> = Vec::new(); 583 let pure_error = AuthorizationClaim::parse("", CREATED_AT, &raw_tags) 584 .expect_err("empty content is not a claim"); 585 let adapter_error = decode_verify_authorization_header(&raw_header(&malformed_claim), &policy) 586 .expect_err("authenticated malformed claim must fail"); 587 assert_eq!(adapter_error.code(), pure_error.code()); 588 assert_eq!(adapter_error.blossom_claim_error(), Some(&pure_error)); 589 assert_eq!(adapter_error.to_string(), pure_error.to_string()); 590 591 let signed = 592 sign_authorization(&keys(), &authored_claim()).expect("sign authored authorization"); 593 let media_policy = AuthorizationValidation::new( 594 AuthorizationTarget::Media(hash()), 595 server(), 596 ServerScopeRequirement::RequiredAnyMatch, 597 CREATED_AT + 1, 598 RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, 599 ) 600 .expect("media validation policy"); 601 let validation_error = decode_verify_authorization_header( 602 encode_authorization_header(&signed).as_str(), 603 &media_policy, 604 ) 605 .expect_err("upload claim must not authorize media endpoint"); 606 assert_eq!(validation_error.code(), "authorization_action_mismatch"); 607 } 608 609 #[test] 610 fn blossom_adapter_error_codes_are_stable_and_distinct() { 611 let errors = [ 612 AuthorizationError::InvalidHeaderWhitespace, 613 AuthorizationError::InvalidHeaderScheme, 614 AuthorizationError::EmptyHeaderPayload, 615 AuthorizationError::HeaderPaddingForbidden, 616 AuthorizationError::InvalidHeaderBase64, 617 AuthorizationError::NonCanonicalHeaderBase64, 618 AuthorizationError::InvalidHeaderUtf8, 619 AuthorizationError::InvalidEventJson, 620 AuthorizationError::InvalidEventKind { actual: 1 }, 621 AuthorizationError::InvalidEventId, 622 AuthorizationError::InvalidEventSignature, 623 AuthorizationError::EventSigning, 624 ]; 625 let mut codes = errors 626 .iter() 627 .map(AuthorizationError::code) 628 .collect::<Vec<_>>(); 629 let before = codes.len(); 630 codes.sort_unstable(); 631 codes.dedup(); 632 assert_eq!(codes.len(), before); 633 assert!(errors.iter().all(|error| !error.to_string().is_empty())); 634 assert_eq!(errors[0].blossom_claim_error(), None); 635 }