blossom.rs (13946B)
1 //! Signed BUD-11 HTTP authorization value adapters. 2 //! 3 //! This module signs, encodes, authenticates, and validates authorization 4 //! values only. HTTP clients, endpoint operations, uploads, downloads, 5 //! retries, and runtime ownership remain outside this crate. 6 7 use core::fmt; 8 9 use alloc::{string::String, vec::Vec}; 10 use base64::{ 11 Engine as _, alphabet, 12 engine::general_purpose::{GeneralPurpose, NO_PAD, URL_SAFE_NO_PAD}, 13 }; 14 use radroots_blossom::{ 15 AuthorizationClaim, Error, 16 authorization::{ 17 AuthoredUploadClaim, AuthorizationValidation, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, 18 ValidatedAuthorizationClaim, 19 }, 20 }; 21 22 use crate::types::{ 23 RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrEventId, 24 RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrTag, 25 RadrootsNostrTagKind, RadrootsNostrTimestamp, 26 }; 27 28 const AUTHORIZATION_SCHEME: &str = "Nostr "; 29 const PERMISSIVE_URL_SAFE_NO_PAD: GeneralPurpose = GeneralPurpose::new( 30 &alphabet::URL_SAFE, 31 NO_PAD.with_decode_allow_trailing_bits(true), 32 ); 33 34 /// A kind-24242 event minted from a strict authored Blossom upload claim. 35 #[derive(Clone, PartialEq, Eq)] 36 pub struct SignedAuthorization { 37 event: RadrootsNostrEvent, 38 } 39 40 impl fmt::Debug for SignedAuthorization { 41 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 42 formatter 43 .debug_struct("SignedAuthorization") 44 .field("event_id", &self.event.id) 45 .field("author", &self.event.pubkey) 46 .field("created_at", &self.event.created_at) 47 .finish_non_exhaustive() 48 } 49 } 50 51 impl SignedAuthorization { 52 /// Converts an already verified exact signer result into an HTTP-only 53 /// authorization value, rejecting every non-BUD-11 event. 54 pub fn from_signed_event( 55 signed_event: &radroots_event::SignedEvent, 56 ) -> Result<Self, AuthorizationError> { 57 if signed_event.kind() != u32::from(RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND) { 58 return Err(AuthorizationError::InvalidEventKind { 59 actual: u64::from(signed_event.kind()), 60 }); 61 } 62 let event: RadrootsNostrEvent = serde_json::from_str(signed_event.raw_json()) 63 .map_err(|_| AuthorizationError::InvalidEventJson)?; 64 // `SignedEvent` has already verified that its retained wire ID matches 65 // the canonical event preimage. Repeating that invariant here would 66 // create an unreachable failure branch; the signature remains an 67 // intentionally separate verification stage on that type. 68 if !event.verify_signature() { 69 return Err(AuthorizationError::InvalidEventSignature); 70 } 71 Ok(Self { event }) 72 } 73 74 pub fn event_id(&self) -> RadrootsNostrEventId { 75 self.event.id 76 } 77 78 pub fn author(&self) -> RadrootsNostrPublicKey { 79 self.event.pubkey 80 } 81 82 pub fn created_at(&self) -> RadrootsNostrTimestamp { 83 self.event.created_at 84 } 85 } 86 87 /// A canonical BUD-11 HTTP `Authorization` value. 88 #[derive(Clone, PartialEq, Eq)] 89 pub struct AuthorizationHeader(String); 90 91 impl AuthorizationHeader { 92 pub fn as_str(&self) -> &str { 93 &self.0 94 } 95 96 pub fn into_string(self) -> String { 97 self.0 98 } 99 } 100 101 impl fmt::Debug for AuthorizationHeader { 102 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 103 formatter 104 .debug_tuple("AuthorizationHeader") 105 .field(&"[REDACTED]") 106 .finish() 107 } 108 } 109 110 impl AsRef<str> for AuthorizationHeader { 111 fn as_ref(&self) -> &str { 112 self.as_str() 113 } 114 } 115 116 /// A signature-verified BUD-11 event whose pure claim policy also passed. 117 #[derive(Clone, PartialEq, Eq)] 118 pub struct VerifiedAuthorization { 119 event: RadrootsNostrEvent, 120 claim: ValidatedAuthorizationClaim, 121 } 122 123 impl fmt::Debug for VerifiedAuthorization { 124 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 125 formatter 126 .debug_struct("VerifiedAuthorization") 127 .field("event_id", &self.event.id) 128 .field("author", &self.event.pubkey) 129 .field("created_at", &self.event.created_at) 130 .finish_non_exhaustive() 131 } 132 } 133 134 impl VerifiedAuthorization { 135 pub fn event_id(&self) -> RadrootsNostrEventId { 136 self.event.id 137 } 138 139 pub fn author(&self) -> RadrootsNostrPublicKey { 140 self.event.pubkey 141 } 142 143 pub fn created_at(&self) -> RadrootsNostrTimestamp { 144 self.event.created_at 145 } 146 147 pub fn claim(&self) -> &ValidatedAuthorizationClaim { 148 &self.claim 149 } 150 } 151 152 /// Failures at the signed Blossom HTTP authorization boundary. 153 #[derive(Clone, Debug, PartialEq, Eq)] 154 #[non_exhaustive] 155 pub enum AuthorizationError { 156 InvalidHeaderWhitespace, 157 InvalidHeaderScheme, 158 EmptyHeaderPayload, 159 HeaderPaddingForbidden, 160 InvalidHeaderBase64, 161 NonCanonicalHeaderBase64, 162 InvalidHeaderUtf8, 163 InvalidEventJson, 164 InvalidEventKind { actual: u64 }, 165 InvalidEventId, 166 InvalidEventSignature, 167 EventSigning, 168 BlossomClaim(Error), 169 } 170 171 impl AuthorizationError { 172 pub const fn code(&self) -> &'static str { 173 match self { 174 Self::InvalidHeaderWhitespace => "invalid_header_whitespace", 175 Self::InvalidHeaderScheme => "invalid_header_scheme", 176 Self::EmptyHeaderPayload => "empty_header_payload", 177 Self::HeaderPaddingForbidden => "header_padding_forbidden", 178 Self::InvalidHeaderBase64 => "invalid_header_base64", 179 Self::NonCanonicalHeaderBase64 => "noncanonical_header_base64", 180 Self::InvalidHeaderUtf8 => "invalid_header_utf8", 181 Self::InvalidEventJson => "invalid_event_json", 182 Self::InvalidEventKind { .. } => "invalid_event_kind", 183 Self::InvalidEventId => "invalid_event_id", 184 Self::InvalidEventSignature => "invalid_event_signature", 185 Self::EventSigning => "event_signing", 186 Self::BlossomClaim(error) => error.code(), 187 } 188 } 189 190 pub fn blossom_claim_error(&self) -> Option<&Error> { 191 match self { 192 Self::BlossomClaim(error) => Some(error), 193 _ => None, 194 } 195 } 196 } 197 198 impl fmt::Display for AuthorizationError { 199 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 200 match self { 201 Self::InvalidHeaderWhitespace => { 202 formatter.write_str("invalid whitespace in Blossom authorization header") 203 } 204 Self::InvalidHeaderScheme => { 205 formatter.write_str("Blossom authorization header must use the Nostr scheme") 206 } 207 Self::EmptyHeaderPayload => { 208 formatter.write_str("Blossom authorization header payload is empty") 209 } 210 Self::HeaderPaddingForbidden => { 211 formatter.write_str("Blossom authorization header Base64url padding is forbidden") 212 } 213 Self::InvalidHeaderBase64 => { 214 formatter.write_str("invalid Blossom authorization header Base64url") 215 } 216 Self::NonCanonicalHeaderBase64 => { 217 formatter.write_str("noncanonical Blossom authorization header Base64url") 218 } 219 Self::InvalidHeaderUtf8 => { 220 formatter.write_str("Blossom authorization event JSON is not UTF-8") 221 } 222 Self::InvalidEventJson => { 223 formatter.write_str("invalid Blossom authorization event JSON") 224 } 225 Self::InvalidEventKind { actual } => write!( 226 formatter, 227 "invalid Blossom authorization event kind {actual}" 228 ), 229 Self::InvalidEventId => formatter.write_str("invalid Blossom authorization event id"), 230 Self::InvalidEventSignature => { 231 formatter.write_str("invalid Blossom authorization event signature") 232 } 233 Self::EventSigning => formatter.write_str("failed to sign Blossom authorization event"), 234 Self::BlossomClaim(error) => error.fmt(formatter), 235 } 236 } 237 } 238 239 impl std::error::Error for AuthorizationError {} 240 241 impl From<Error> for AuthorizationError { 242 fn from(error: Error) -> Self { 243 Self::BlossomClaim(error) 244 } 245 } 246 247 /// Sign a direct kind-24242 event from a strict authored BUD-11 upload claim. 248 pub fn sign_authorization( 249 keys: &RadrootsNostrKeys, 250 claim: &AuthoredUploadClaim, 251 ) -> Result<SignedAuthorization, AuthorizationError> { 252 let wire = claim.wire_parts(); 253 let tags = wire 254 .tags() 255 .iter() 256 .map(|tag| { 257 let (kind, values) = tag 258 .split_first() 259 .expect("authored Blossom wire tags always contain a kind"); 260 RadrootsNostrTag::custom( 261 RadrootsNostrTagKind::custom(kind.as_str()), 262 values.iter().cloned(), 263 ) 264 }) 265 .collect::<Vec<_>>(); 266 finish_signed_event( 267 RadrootsNostrEventBuilderUnchecked::new( 268 RadrootsNostrKind::Custom(wire.kind()), 269 wire.content(), 270 ) 271 .tags(tags) 272 .custom_created_at(RadrootsNostrTimestamp::from_secs(wire.created_at())) 273 .sign_with_keys(keys), 274 ) 275 } 276 277 fn finish_signed_event<E>( 278 result: Result<RadrootsNostrEvent, E>, 279 ) -> Result<SignedAuthorization, AuthorizationError> { 280 result 281 .map(|event| SignedAuthorization { event }) 282 .map_err(|_| AuthorizationError::EventSigning) 283 } 284 285 /// Encode a signed BUD-11 event as a canonical `Nostr` authorization value. 286 pub fn encode_authorization_header(authorization: &SignedAuthorization) -> AuthorizationHeader { 287 // `nostr::Event` contains no fallible serializer fields or non-string map keys. 288 let json = serde_json::to_vec(&authorization.event) 289 .expect("Nostr event JSON serialization is infallible"); 290 let payload = URL_SAFE_NO_PAD.encode(json); 291 AuthorizationHeader(format!("{AUTHORIZATION_SCHEME}{payload}")) 292 } 293 294 /// Converts one verified generic signer receipt into a canonical BUD-11 HTTP 295 /// authorization value without exposing or accepting secret key material. 296 pub fn encode_signed_event_authorization_header( 297 signed_event: &radroots_event::SignedEvent, 298 ) -> Result<AuthorizationHeader, AuthorizationError> { 299 SignedAuthorization::from_signed_event(signed_event) 300 .map(|authorization| encode_authorization_header(&authorization)) 301 } 302 303 /// Decode, authenticate, parse, and validate a BUD-11 authorization value. 304 pub fn decode_verify_authorization_header( 305 header: &str, 306 validation: &AuthorizationValidation, 307 ) -> Result<VerifiedAuthorization, AuthorizationError> { 308 if header.trim_start() != header { 309 return Err(AuthorizationError::InvalidHeaderWhitespace); 310 } 311 let bytes = header.as_bytes(); 312 let Some(first_space) = bytes.iter().position(|byte| *byte == b' ') else { 313 return Err(AuthorizationError::InvalidHeaderScheme); 314 }; 315 if !bytes[..first_space].eq_ignore_ascii_case(b"Nostr") { 316 return Err(AuthorizationError::InvalidHeaderScheme); 317 } 318 let payload_start = bytes[first_space..] 319 .iter() 320 .position(|byte| *byte != b' ') 321 .map_or(bytes.len(), |offset| first_space + offset); 322 let payload = &header[payload_start..]; 323 if payload.is_empty() { 324 return Err(AuthorizationError::EmptyHeaderPayload); 325 } 326 if payload.chars().any(char::is_whitespace) { 327 return Err(AuthorizationError::InvalidHeaderWhitespace); 328 } 329 if payload.contains('=') { 330 return Err(AuthorizationError::HeaderPaddingForbidden); 331 } 332 let decoded = PERMISSIVE_URL_SAFE_NO_PAD 333 .decode(payload) 334 .map_err(|_| AuthorizationError::InvalidHeaderBase64)?; 335 if URL_SAFE_NO_PAD.encode(&decoded) != payload { 336 return Err(AuthorizationError::NonCanonicalHeaderBase64); 337 } 338 let json = String::from_utf8(decoded).map_err(|_| AuthorizationError::InvalidHeaderUtf8)?; 339 validate_raw_event_json(&json)?; 340 let event: RadrootsNostrEvent = 341 serde_json::from_str(&json).map_err(|_| AuthorizationError::InvalidEventJson)?; 342 if !event.verify_id() { 343 return Err(AuthorizationError::InvalidEventId); 344 } 345 if !event.verify_signature() { 346 return Err(AuthorizationError::InvalidEventSignature); 347 } 348 349 let tags: Vec<Vec<String>> = event 350 .tags 351 .iter() 352 .map(|tag| tag.as_slice().to_vec()) 353 .collect(); 354 let claim = AuthorizationClaim::parse(&event.content, event.created_at.as_secs(), &tags)? 355 .validate(validation)?; 356 357 Ok(VerifiedAuthorization { event, claim }) 358 } 359 360 fn validate_raw_event_json(json: &str) -> Result<(), AuthorizationError> { 361 const EVENT_FIELDS: [&str; 7] = [ 362 "id", 363 "pubkey", 364 "created_at", 365 "kind", 366 "tags", 367 "content", 368 "sig", 369 ]; 370 371 let value: serde_json::Value = 372 serde_json::from_str(json).map_err(|_| AuthorizationError::InvalidEventJson)?; 373 let object = value 374 .as_object() 375 .ok_or(AuthorizationError::InvalidEventJson)?; 376 if object.len() != EVENT_FIELDS.len() 377 || !EVENT_FIELDS.iter().all(|field| object.contains_key(*field)) 378 { 379 return Err(AuthorizationError::InvalidEventJson); 380 } 381 382 let actual_kind = object 383 .get("kind") 384 .and_then(serde_json::Value::as_u64) 385 .ok_or(AuthorizationError::InvalidEventJson)?; 386 if actual_kind != u64::from(RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND) { 387 return Err(AuthorizationError::InvalidEventKind { 388 actual: actual_kind, 389 }); 390 } 391 Ok(()) 392 } 393 394 #[cfg(test)] 395 mod tests { 396 use super::*; 397 398 #[test] 399 fn blossom_signing_failure_maps_to_typed_adapter_error() { 400 let result = finish_signed_event(Err::<RadrootsNostrEvent, ()>(())); 401 assert_eq!(result, Err(AuthorizationError::EventSigning)); 402 } 403 }