lib

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

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 }