Skip to main content

softchat/
media.rs

1//! Softchat inline attachment metadata, compatibility crypto, and NIP-98 plans.
2
3use std::collections::BTreeSet;
4use std::fmt;
5use std::sync::{Mutex, MutexGuard};
6
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD;
9use chacha20::ChaCha20;
10use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek};
11use chacha20poly1305::aead::{Aead, KeyInit};
12use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
13use nostr::secp256k1::rand::RngCore;
14use nostr::secp256k1::rand::rngs::OsRng;
15use poly1305::universal_hash::UniversalHash;
16use poly1305::{Block as Poly1305Block, Poly1305, Tag as Poly1305Tag};
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use subtle::ConstantTimeEq;
20use url::Url;
21use zeroize::Zeroize;
22
23use crate::{LocalIdentity, NostrEventDraft, NostrEventKind, NostrTag, SoftchatError};
24
25/// Maximum number of fields after the leading `imeta` tag name.
26pub const MAX_ATTACHMENT_METADATA_FIELDS: usize = 128;
27/// Maximum number of fallback URLs in one canonical attachment.
28pub const MAX_ATTACHMENT_FALLBACKS: usize = 16;
29/// Maximum input for the whole-buffer released-client compatibility helper.
30///
31/// Production large-file adapters must use staged files and a reviewed
32/// streaming implementation rather than crossing FFI with this operation.
33pub const MAX_ATTACHMENT_COMPATIBILITY_BYTES: usize = 16 * 1024 * 1024;
34/// Maximum plaintext bytes processed by one staged streaming operation.
35pub const MAX_ATTACHMENT_STREAM_BYTES: u64 = 16 * 1024 * 1024 * 1024;
36/// Maximum bytes accepted by one cross-language streaming call.
37pub const MAX_ATTACHMENT_STREAM_CHUNK_BYTES: usize = 1024 * 1024;
38/// Maximum normalized URL bytes accepted by one NIP-98 plan.
39pub const MAX_HTTP_AUTHORIZATION_URL_BYTES: usize = 8 * 1024;
40/// Maximum request-body bytes hashed by the whole-buffer NIP-98 helper.
41pub const MAX_HTTP_AUTHORIZATION_PAYLOAD_BYTES: usize = MAX_ATTACHMENT_COMPATIBILITY_BYTES;
42const ATTACHMENT_KEY_BYTES: usize = 32;
43const ATTACHMENT_NONCE_BYTES: usize = 12;
44const ATTACHMENT_TAG_BYTES: usize = 16;
45
46/// Parsed canonical Softchat `imeta` metadata.
47#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(rename_all = "camelCase", deny_unknown_fields)]
49#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
50pub struct AttachmentMetadata {
51    /// Required absolute HTTPS URL.
52    pub url: String,
53    /// Optional lowercase MIME type.
54    pub mime_type: Option<String>,
55    /// Optional SHA-256 hash of the referenced object.
56    pub sha256: Option<String>,
57    /// Optional SHA-256 hash of the pre-transform object.
58    pub original_sha256: Option<String>,
59    /// Optional byte size.
60    pub byte_size: Option<u64>,
61    /// Optional positive `<width>x<height>` dimensions.
62    pub dimensions: Option<String>,
63    /// Optional display name.
64    pub name: Option<String>,
65    /// Optional magnet URI.
66    pub magnet: Option<String>,
67    /// Optional torrent info hash.
68    pub infohash: Option<String>,
69    /// Optional blurhash.
70    pub blurhash: Option<String>,
71    /// Optional absolute HTTPS thumbnail URL.
72    pub thumbnail_url: Option<String>,
73    /// Optional absolute HTTPS preview URL.
74    pub image_url: Option<String>,
75    /// Optional summary.
76    pub summary: Option<String>,
77    /// Optional accessibility text.
78    pub alt: Option<String>,
79    /// Sorted absolute HTTPS fallback URLs.
80    pub fallback_urls: Vec<String>,
81    /// Optional storage service identifier.
82    pub service: Option<String>,
83    /// Opaque versioned encryption metadata. Never include it in diagnostics.
84    pub encryption: Option<String>,
85    /// Unknown raw `key value` fields retained in received order.
86    pub unknown_fields: Vec<String>,
87}
88
89impl AttachmentMetadata {
90    /// Parse a complete raw `imeta` tag.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`SoftchatError::InvalidAttachmentMetadata`] for duplicate
95    /// singleton fields, missing required values, or invalid typed values.
96    pub fn from_tag(values: &[String]) -> Result<Self, SoftchatError> {
97        if values.first().map(String::as_str) != Some("imeta")
98            || values.len() < 2
99            || values.len() - 1 > MAX_ATTACHMENT_METADATA_FIELDS
100        {
101            return Err(SoftchatError::InvalidAttachmentMetadata);
102        }
103
104        let mut result = Self::default();
105        let mut seen = BTreeSet::new();
106        for raw in &values[1..] {
107            let (key, value) = split_field(raw)?;
108            if key == "fallback" {
109                if result.fallback_urls.len() >= MAX_ATTACHMENT_FALLBACKS {
110                    return Err(SoftchatError::InvalidAttachmentMetadata);
111                }
112                validate_https_url(value)?;
113                result.fallback_urls.push(value.to_owned());
114                continue;
115            }
116            if is_known_singleton(key) && !seen.insert(key.to_owned()) {
117                return Err(SoftchatError::InvalidAttachmentMetadata);
118            }
119            match key {
120                "url" => {
121                    validate_https_url(value)?;
122                    result.url = value.to_owned();
123                }
124                "m" => {
125                    validate_mime(value)?;
126                    result.mime_type = Some(value.to_owned());
127                }
128                "x" => {
129                    validate_sha256(value)?;
130                    result.sha256 = Some(value.to_owned());
131                }
132                "ox" => {
133                    validate_sha256(value)?;
134                    result.original_sha256 = Some(value.to_owned());
135                }
136                "size" => {
137                    result.byte_size = Some(
138                        value
139                            .parse()
140                            .map_err(|_| SoftchatError::InvalidAttachmentMetadata)?,
141                    );
142                }
143                "dim" => {
144                    validate_dimensions(value)?;
145                    result.dimensions = Some(value.to_owned());
146                }
147                "name" => result.name = Some(value.to_owned()),
148                "magnet" => {
149                    if !value.starts_with("magnet:?") {
150                        return Err(SoftchatError::InvalidAttachmentMetadata);
151                    }
152                    result.magnet = Some(value.to_owned());
153                }
154                "i" => result.infohash = Some(value.to_owned()),
155                "blurhash" => result.blurhash = Some(value.to_owned()),
156                "thumb" => {
157                    validate_https_url(value)?;
158                    result.thumbnail_url = Some(value.to_owned());
159                }
160                "image" => {
161                    validate_https_url(value)?;
162                    result.image_url = Some(value.to_owned());
163                }
164                "summary" => result.summary = Some(value.to_owned()),
165                "alt" => result.alt = Some(value.to_owned()),
166                "service" => result.service = Some(value.to_owned()),
167                "enc" => result.encryption = Some(value.to_owned()),
168                _ => result.unknown_fields.push(raw.clone()),
169            }
170        }
171        if result.url.is_empty() {
172            return Err(SoftchatError::InvalidAttachmentMetadata);
173        }
174        result.fallback_urls.sort();
175        Ok(result)
176    }
177
178    /// Emit the canonical field order from the accepted Softchat profile.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`SoftchatError::InvalidAttachmentMetadata`] if the value is
183    /// malformed or contains a duplicate known field through `unknown_fields`.
184    pub fn to_tag(&self) -> Result<NostrTag, SoftchatError> {
185        self.validate_for_write()?;
186        let mut values = vec!["imeta".to_owned(), format!("url {}", self.url)];
187        push_optional(&mut values, "m", &self.mime_type);
188        push_optional(&mut values, "x", &self.sha256);
189        push_optional(&mut values, "ox", &self.original_sha256);
190        if let Some(byte_size) = self.byte_size {
191            values.push(format!("size {byte_size}"));
192        }
193        push_optional(&mut values, "dim", &self.dimensions);
194        push_optional(&mut values, "name", &self.name);
195        push_optional(&mut values, "magnet", &self.magnet);
196        push_optional(&mut values, "i", &self.infohash);
197        push_optional(&mut values, "blurhash", &self.blurhash);
198        push_optional(&mut values, "thumb", &self.thumbnail_url);
199        push_optional(&mut values, "image", &self.image_url);
200        push_optional(&mut values, "summary", &self.summary);
201        push_optional(&mut values, "alt", &self.alt);
202        let mut fallbacks = self.fallback_urls.clone();
203        fallbacks.sort();
204        for fallback in fallbacks {
205            values.push(format!("fallback {fallback}"));
206        }
207        push_optional(&mut values, "service", &self.service);
208        push_optional(&mut values, "enc", &self.encryption);
209        values.extend(self.unknown_fields.iter().cloned());
210        NostrTag::new(values).map_err(|_| SoftchatError::InvalidAttachmentMetadata)
211    }
212
213    fn validate_for_write(&self) -> Result<(), SoftchatError> {
214        validate_https_url(&self.url)?;
215        if let Some(value) = &self.mime_type {
216            validate_mime(value)?;
217        }
218        for value in [&self.sha256, &self.original_sha256].into_iter().flatten() {
219            validate_sha256(value)?;
220        }
221        if let Some(value) = &self.dimensions {
222            validate_dimensions(value)?;
223        }
224        for value in [&self.thumbnail_url, &self.image_url].into_iter().flatten() {
225            validate_https_url(value)?;
226        }
227        if self.fallback_urls.len() > MAX_ATTACHMENT_FALLBACKS {
228            return Err(SoftchatError::InvalidAttachmentMetadata);
229        }
230        for value in &self.fallback_urls {
231            validate_https_url(value)?;
232        }
233        let mut unknown_keys = BTreeSet::new();
234        for raw in &self.unknown_fields {
235            let (key, _) = split_field(raw)?;
236            if is_known_singleton(key) || key == "fallback" || !unknown_keys.insert(key) {
237                return Err(SoftchatError::InvalidAttachmentMetadata);
238            }
239        }
240        if self.to_field_count() > MAX_ATTACHMENT_METADATA_FIELDS {
241            return Err(SoftchatError::InvalidAttachmentMetadata);
242        }
243        Ok(())
244    }
245
246    fn to_field_count(&self) -> usize {
247        1 + [
248            self.mime_type.is_some(),
249            self.sha256.is_some(),
250            self.original_sha256.is_some(),
251            self.byte_size.is_some(),
252            self.dimensions.is_some(),
253            self.name.is_some(),
254            self.magnet.is_some(),
255            self.infohash.is_some(),
256            self.blurhash.is_some(),
257            self.thumbnail_url.is_some(),
258            self.image_url.is_some(),
259            self.summary.is_some(),
260            self.alt.is_some(),
261            self.service.is_some(),
262            self.encryption.is_some(),
263        ]
264        .into_iter()
265        .filter(|present| *present)
266        .count()
267            + self.fallback_urls.len()
268            + self.unknown_fields.len()
269    }
270}
271
272/// Final authentication and hash values for one staged attachment transform.
273#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
274#[serde(rename_all = "camelCase")]
275#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
276pub struct AttachmentStreamFinal {
277    /// Detached 16-byte Poly1305 tag appended after encrypted bytes.
278    pub authentication_tag: Vec<u8>,
279    /// Lowercase SHA-256 of the complete plaintext.
280    pub plaintext_sha256: String,
281    /// Lowercase SHA-256 of `nonce || ciphertext || tag`.
282    pub encrypted_sha256: String,
283    /// Complete plaintext/ciphertext byte count, excluding nonce and tag.
284    pub byte_count: u64,
285}
286
287struct AttachmentStreamState {
288    cipher: ChaCha20,
289    mac: Poly1305,
290    mac_tail: Vec<u8>,
291    plaintext_hash: Sha256,
292    encrypted_hash: Sha256,
293    byte_count: u64,
294}
295
296impl fmt::Debug for AttachmentStreamState {
297    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
298        formatter
299            .debug_struct("AttachmentStreamState")
300            .field("byte_count", &self.byte_count)
301            .finish_non_exhaustive()
302    }
303}
304
305impl AttachmentStreamState {
306    fn new(mut key_bytes: Vec<u8>, nonce: &[u8]) -> Result<Self, SoftchatError> {
307        if key_bytes.len() != ATTACHMENT_KEY_BYTES || nonce.len() != ATTACHMENT_NONCE_BYTES {
308            key_bytes.zeroize();
309            return Err(SoftchatError::AttachmentEncryptionFailed);
310        }
311        let mut cipher = ChaCha20::new_from_slices(&key_bytes, nonce).map_err(|_| {
312            key_bytes.zeroize();
313            SoftchatError::AttachmentEncryptionFailed
314        })?;
315        key_bytes.zeroize();
316
317        let mut mac_key = poly1305::Key::default();
318        cipher.apply_keystream(&mut mac_key);
319        let mac = Poly1305::new(&mac_key);
320        mac_key.zeroize();
321        cipher.seek(64);
322
323        let mut encrypted_hash = Sha256::new();
324        encrypted_hash.update(nonce);
325        Ok(Self {
326            cipher,
327            mac,
328            mac_tail: Vec::with_capacity(poly1305::BLOCK_SIZE),
329            plaintext_hash: Sha256::new(),
330            encrypted_hash,
331            byte_count: 0,
332        })
333    }
334
335    fn reserve_chunk(
336        &mut self,
337        chunk_len: usize,
338        error: SoftchatError,
339    ) -> Result<(), SoftchatError> {
340        if chunk_len > MAX_ATTACHMENT_STREAM_CHUNK_BYTES {
341            return Err(error);
342        }
343        let chunk_len = u64::try_from(chunk_len).map_err(|_| error)?;
344        self.byte_count = self
345            .byte_count
346            .checked_add(chunk_len)
347            .filter(|total| *total <= MAX_ATTACHMENT_STREAM_BYTES)
348            .ok_or(error)?;
349        Ok(())
350    }
351
352    fn authenticate_ciphertext(&mut self, ciphertext: &[u8]) {
353        let mut remaining = ciphertext;
354        if !self.mac_tail.is_empty() {
355            let needed = poly1305::BLOCK_SIZE - self.mac_tail.len();
356            let copied = needed.min(remaining.len());
357            self.mac_tail.extend_from_slice(&remaining[..copied]);
358            remaining = &remaining[copied..];
359            if self.mac_tail.len() == poly1305::BLOCK_SIZE {
360                self.mac
361                    .update(&[Poly1305Block::clone_from_slice(&self.mac_tail)]);
362                self.mac_tail.clear();
363            }
364        }
365
366        let mut blocks = remaining.chunks_exact(poly1305::BLOCK_SIZE);
367        for block in &mut blocks {
368            self.mac.update(&[Poly1305Block::clone_from_slice(block)]);
369        }
370        self.mac_tail.extend_from_slice(blocks.remainder());
371    }
372
373    fn finish_mac(mut self) -> (Poly1305Tag, Sha256, Sha256, u64) {
374        if !self.mac_tail.is_empty() {
375            let mut block = Poly1305Block::default();
376            block[..self.mac_tail.len()].copy_from_slice(&self.mac_tail);
377            self.mac.update(&[block]);
378            self.mac_tail.zeroize();
379        }
380
381        let mut lengths = Poly1305Block::default();
382        lengths[8..].copy_from_slice(&self.byte_count.to_le_bytes());
383        self.mac.update(&[lengths]);
384        let tag = self.mac.finalize();
385        (
386            tag,
387            self.plaintext_hash,
388            self.encrypted_hash,
389            self.byte_count,
390        )
391    }
392}
393
394/// Incremental encryption compatible with released clients' combined
395/// `nonce12 || ciphertext || tag16` attachment format.
396///
397/// The host writes [`Self::nonce`] first, each [`Self::update`] result to a
398/// private staged file, then [`AttachmentStreamFinal::authentication_tag`].
399/// It publishes the staged file only after `finish` succeeds.
400///
401/// [Guide and performance](https://softchat-sdk.jackl.dev/media-and-http/#stream-encryption)
402#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
403pub struct AttachmentEncryptionStream {
404    encryption_descriptor: String,
405    nonce: [u8; ATTACHMENT_NONCE_BYTES],
406    state: Mutex<Option<AttachmentStreamState>>,
407}
408
409impl fmt::Debug for AttachmentEncryptionStream {
410    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
411        formatter
412            .debug_struct("AttachmentEncryptionStream")
413            .finish_non_exhaustive()
414    }
415}
416
417#[cfg_attr(feature = "native-bindings", uniffi::export)]
418impl AttachmentEncryptionStream {
419    /// Start one stream with a Rust-generated 256-bit attachment key.
420    ///
421    /// The returned [`Self::encryption_descriptor`] is the released
422    /// base64-encoded `enc` value retained in authenticated attachment
423    /// metadata. Raw key bytes never cross the language boundary.
424    ///
425    /// # Errors
426    ///
427    /// Returns a stable attachment-encryption failure.
428    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
429    pub fn random() -> Result<Self, SoftchatError> {
430        let mut key_bytes = vec![0_u8; ATTACHMENT_KEY_BYTES];
431        OsRng.fill_bytes(&mut key_bytes);
432        Self::new(key_bytes)
433    }
434
435    /// Start one random-nonce attachment encryption stream.
436    ///
437    /// # Errors
438    ///
439    /// Returns a stable failure unless `key_bytes` contains exactly 32 bytes.
440    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
441    pub fn new(key_bytes: Vec<u8>) -> Result<Self, SoftchatError> {
442        if key_bytes.len() != ATTACHMENT_KEY_BYTES {
443            return Err(SoftchatError::AttachmentEncryptionFailed);
444        }
445        let encryption_descriptor = STANDARD.encode(&key_bytes);
446        let mut nonce = [0_u8; ATTACHMENT_NONCE_BYTES];
447        OsRng.fill_bytes(&mut nonce);
448        Self::new_with_nonce_and_descriptor(key_bytes, nonce, encryption_descriptor)
449    }
450
451    /// Return the released-client base64 `enc` metadata value.
452    ///
453    /// This descriptor grants decryption authority and must never be logged.
454    #[must_use]
455    pub fn encryption_descriptor(&self) -> String {
456        self.encryption_descriptor.clone()
457    }
458
459    /// Return the 12-byte prefix that must be written before ciphertext.
460    #[must_use]
461    pub fn nonce(&self) -> Vec<u8> {
462        self.nonce.to_vec()
463    }
464
465    /// Encrypt one bounded plaintext chunk.
466    ///
467    /// # Errors
468    ///
469    /// Returns a stable failure after finish/cancel or when cumulative or
470    /// per-call bounds are exceeded.
471    pub fn update(&self, mut plaintext: Vec<u8>) -> Result<Vec<u8>, SoftchatError> {
472        let mut guard = lock_stream(&self.state);
473        let state = guard
474            .as_mut()
475            .ok_or(SoftchatError::AttachmentEncryptionFailed)?;
476        state.reserve_chunk(plaintext.len(), SoftchatError::AttachmentTooLarge)?;
477        state.plaintext_hash.update(&plaintext);
478        state.cipher.apply_keystream(&mut plaintext);
479        state.authenticate_ciphertext(&plaintext);
480        state.encrypted_hash.update(&plaintext);
481        Ok(plaintext)
482    }
483
484    /// Finish authentication and consume the stream.
485    ///
486    /// # Errors
487    ///
488    /// Returns a stable failure after finish or cancellation.
489    pub fn finish(&self) -> Result<AttachmentStreamFinal, SoftchatError> {
490        let state = lock_stream(&self.state)
491            .take()
492            .ok_or(SoftchatError::AttachmentEncryptionFailed)?;
493        let (tag, plaintext_hash, mut encrypted_hash, byte_count) = state.finish_mac();
494        encrypted_hash.update(tag);
495        Ok(AttachmentStreamFinal {
496            authentication_tag: tag.to_vec(),
497            plaintext_sha256: hex::encode(plaintext_hash.finalize()),
498            encrypted_sha256: hex::encode(encrypted_hash.finalize()),
499            byte_count,
500        })
501    }
502
503    /// Cancel and zeroize remaining stream state.
504    pub fn cancel(&self) {
505        lock_stream(&self.state).take();
506    }
507}
508
509impl AttachmentEncryptionStream {
510    #[cfg(test)]
511    fn new_with_nonce(
512        key_bytes: Vec<u8>,
513        nonce: [u8; ATTACHMENT_NONCE_BYTES],
514    ) -> Result<Self, SoftchatError> {
515        if key_bytes.len() != ATTACHMENT_KEY_BYTES {
516            return Err(SoftchatError::AttachmentEncryptionFailed);
517        }
518        let encryption_descriptor = STANDARD.encode(&key_bytes);
519        Self::new_with_nonce_and_descriptor(key_bytes, nonce, encryption_descriptor)
520    }
521
522    fn new_with_nonce_and_descriptor(
523        key_bytes: Vec<u8>,
524        nonce: [u8; ATTACHMENT_NONCE_BYTES],
525        encryption_descriptor: String,
526    ) -> Result<Self, SoftchatError> {
527        Ok(Self {
528            state: Mutex::new(Some(AttachmentStreamState::new(key_bytes, &nonce)?)),
529            encryption_descriptor,
530            nonce,
531        })
532    }
533}
534
535/// Incremental authenticated decryption for a private staged destination.
536///
537/// `update` returns unauthenticated plaintext bytes. The host must write them
538/// only to a private temporary destination and must delete that destination
539/// unless [`Self::finish`] authenticates the final tag. No plaintext may be
540/// displayed, indexed, or atomically published before that success.
541#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
542pub struct AttachmentDecryptionStream {
543    state: Mutex<Option<AttachmentStreamState>>,
544}
545
546impl fmt::Debug for AttachmentDecryptionStream {
547    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
548        formatter
549            .debug_struct("AttachmentDecryptionStream")
550            .finish_non_exhaustive()
551    }
552}
553
554#[cfg_attr(feature = "native-bindings", uniffi::export)]
555impl AttachmentDecryptionStream {
556    /// Start decryption from released base64 `enc` metadata and nonce prefix.
557    ///
558    /// # Errors
559    ///
560    /// All malformed descriptor, nonce, and key failures map to one redacted
561    /// attachment-decryption error.
562    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
563    pub fn from_encryption_descriptor(
564        encryption_descriptor: String,
565        nonce: Vec<u8>,
566    ) -> Result<Self, SoftchatError> {
567        let key_bytes = STANDARD
568            .decode(encryption_descriptor)
569            .map_err(|_| SoftchatError::AttachmentDecryptionFailed)?;
570        Self::new(key_bytes, nonce)
571    }
572
573    /// Start decryption from the 12-byte combined-format prefix.
574    ///
575    /// # Errors
576    ///
577    /// Returns a stable failure for an invalid key or nonce length.
578    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
579    pub fn new(key_bytes: Vec<u8>, nonce: Vec<u8>) -> Result<Self, SoftchatError> {
580        AttachmentStreamState::new(key_bytes, &nonce)
581            .map(|state| Self {
582                state: Mutex::new(Some(state)),
583            })
584            .map_err(|_| SoftchatError::AttachmentDecryptionFailed)
585    }
586
587    /// Decrypt one bounded ciphertext chunk into private staged bytes.
588    ///
589    /// # Errors
590    ///
591    /// Returns a stable failure after finish/cancel or when bounds are
592    /// exceeded.
593    pub fn update(&self, mut ciphertext: Vec<u8>) -> Result<Vec<u8>, SoftchatError> {
594        let mut guard = lock_stream(&self.state);
595        let state = guard
596            .as_mut()
597            .ok_or(SoftchatError::AttachmentDecryptionFailed)?;
598        state.reserve_chunk(ciphertext.len(), SoftchatError::AttachmentTooLarge)?;
599        state.authenticate_ciphertext(&ciphertext);
600        state.encrypted_hash.update(&ciphertext);
601        state.cipher.apply_keystream(&mut ciphertext);
602        state.plaintext_hash.update(&ciphertext);
603        Ok(ciphertext)
604    }
605
606    /// Authenticate the final 16-byte tag before an atomic staged-file move.
607    ///
608    /// # Errors
609    ///
610    /// Returns a stable decryption failure for a wrong key, tampering, invalid
611    /// tag length, cancellation, or repeated finish.
612    pub fn finish(
613        &self,
614        authentication_tag: Vec<u8>,
615    ) -> Result<AttachmentStreamFinal, SoftchatError> {
616        if authentication_tag.len() != ATTACHMENT_TAG_BYTES {
617            return Err(SoftchatError::AttachmentDecryptionFailed);
618        }
619        let state = lock_stream(&self.state)
620            .take()
621            .ok_or(SoftchatError::AttachmentDecryptionFailed)?;
622        let (computed_tag, plaintext_hash, mut encrypted_hash, byte_count) = state.finish_mac();
623        let expected_tag = Poly1305Tag::clone_from_slice(&authentication_tag);
624        if !bool::from(computed_tag.ct_eq(&expected_tag)) {
625            return Err(SoftchatError::AttachmentDecryptionFailed);
626        }
627        encrypted_hash.update(&authentication_tag);
628        Ok(AttachmentStreamFinal {
629            authentication_tag,
630            plaintext_sha256: hex::encode(plaintext_hash.finalize()),
631            encrypted_sha256: hex::encode(encrypted_hash.finalize()),
632            byte_count,
633        })
634    }
635
636    /// Cancel and zeroize remaining stream state.
637    pub fn cancel(&self) {
638        lock_stream(&self.state).take();
639    }
640}
641
642fn lock_stream<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
643    match mutex.lock() {
644        Ok(guard) => guard,
645        Err(poisoned) => poisoned.into_inner(),
646    }
647}
648
649/// Immutable NIP-98 authorization result for platform HTTP adapters.
650#[derive(Clone, Debug, Eq, PartialEq)]
651pub struct HttpAuthorizationPlan {
652    /// Uppercase HTTP method bound by the event.
653    pub method: String,
654    /// Exact normalized URL bound by the event.
655    pub url: String,
656    /// Lowercase payload SHA-256, or empty when the request has no body.
657    pub payload_sha256: String,
658    /// Complete `Nostr <base64-event-json>` header value.
659    pub authorization_header: String,
660    /// Signed authorization event for diagnostics-free validation.
661    pub event: crate::SignedNostrEvent,
662    /// Redirects require a fresh plan; adapters must not forward this header.
663    pub allow_authorization_on_redirect: bool,
664}
665
666/// Binding record for an immutable NIP-98 authorization result.
667#[cfg(any(
668    feature = "sqlite-storage",
669    feature = "native-bindings",
670    all(feature = "javascript-bindings", target_arch = "wasm32"),
671    test
672))]
673#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
674#[serde(rename_all = "camelCase")]
675#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
676#[allow(
677    unreachable_pub,
678    reason = "UniFFI requires a public type inside this private adapter module"
679)]
680pub struct FfiHttpAuthorizationPlan {
681    /// Uppercase HTTP method bound by the event.
682    pub method: String,
683    /// Exact normalized URL bound by the event.
684    pub url: String,
685    /// Lowercase payload SHA-256, or empty when the request has no body.
686    pub payload_sha256: String,
687    /// Complete `Nostr <base64-event-json>` header value.
688    pub authorization_header: String,
689    /// Flat signed-event binding value.
690    pub event: crate::event::SignedEvent,
691    /// Redirects require a fresh plan.
692    pub allow_authorization_on_redirect: bool,
693}
694
695#[cfg(any(
696    feature = "sqlite-storage",
697    feature = "native-bindings",
698    all(feature = "javascript-bindings", target_arch = "wasm32"),
699    test
700))]
701impl From<HttpAuthorizationPlan> for FfiHttpAuthorizationPlan {
702    fn from(plan: HttpAuthorizationPlan) -> Self {
703        Self {
704            method: plan.method,
705            url: plan.url,
706            payload_sha256: plan.payload_sha256,
707            authorization_header: plan.authorization_header,
708            event: crate::event::SignedEvent::from(&plan.event),
709            allow_authorization_on_redirect: plan.allow_authorization_on_redirect,
710        }
711    }
712}
713
714/// Parse one complete `imeta` tag at generated binding boundaries.
715///
716/// # Errors
717///
718/// Returns a stable metadata error for malformed input.
719#[cfg_attr(feature = "native-bindings", uniffi::export)]
720pub fn parse_attachment_metadata(
721    tag_values: Vec<String>,
722) -> Result<AttachmentMetadata, SoftchatError> {
723    AttachmentMetadata::from_tag(&tag_values)
724}
725
726/// Encrypt a bounded released-client-compatible attachment value.
727///
728/// The output is `12-byte nonce || ciphertext || 16-byte tag`, matching
729/// CryptoKit `ChaChaPoly.SealedBox.combined` and the released Android writer.
730/// Large files must use platform-owned staged-file adapters; this helper is
731/// intentionally bounded so callers cannot accidentally buffer arbitrary
732/// attachments across FFI.
733///
734/// # Errors
735///
736/// Returns a stable key, size, or encryption failure.
737#[cfg_attr(feature = "native-bindings", uniffi::export)]
738pub fn encrypt_attachment_bytes(
739    plaintext: Vec<u8>,
740    key_bytes: Vec<u8>,
741) -> Result<Vec<u8>, SoftchatError> {
742    validate_attachment_input(plaintext.len(), key_bytes.len())?;
743    let cipher = ChaCha20Poly1305::new(Key::from_slice(&key_bytes));
744    let mut nonce_bytes = [0_u8; ATTACHMENT_NONCE_BYTES];
745    OsRng.fill_bytes(&mut nonce_bytes);
746    let ciphertext = cipher
747        .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_ref())
748        .map_err(|_| SoftchatError::AttachmentEncryptionFailed)?;
749    let mut output = Vec::with_capacity(ATTACHMENT_NONCE_BYTES + ciphertext.len());
750    output.extend_from_slice(&nonce_bytes);
751    output.extend_from_slice(&ciphertext);
752    Ok(output)
753}
754
755/// Authenticate and decrypt a bounded released-client attachment value.
756///
757/// # Errors
758///
759/// All malformed nonce, tag, key, and authentication failures map to one
760/// redacted error.
761#[cfg_attr(feature = "native-bindings", uniffi::export)]
762pub fn decrypt_attachment_bytes(
763    encrypted: Vec<u8>,
764    key_bytes: Vec<u8>,
765) -> Result<Vec<u8>, SoftchatError> {
766    if key_bytes.len() != ATTACHMENT_KEY_BYTES
767        || encrypted.len() < ATTACHMENT_NONCE_BYTES + ATTACHMENT_TAG_BYTES
768        || encrypted.len()
769            > MAX_ATTACHMENT_COMPATIBILITY_BYTES + ATTACHMENT_NONCE_BYTES + ATTACHMENT_TAG_BYTES
770    {
771        return Err(SoftchatError::AttachmentDecryptionFailed);
772    }
773    let (nonce_bytes, ciphertext) = encrypted.split_at(ATTACHMENT_NONCE_BYTES);
774    let cipher = ChaCha20Poly1305::new(Key::from_slice(&key_bytes));
775    cipher
776        .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
777        .map_err(|_| SoftchatError::AttachmentDecryptionFailed)
778}
779
780/// Create an immutable, signed NIP-98 HTTP authorization plan.
781///
782/// Platform adapters execute the request and must create a fresh plan after
783/// any redirect. The Authorization header is deliberately not reusable on a
784/// different method or URL.
785///
786/// # Errors
787///
788/// Returns [`SoftchatError::InvalidHttpAuthorization`] for invalid methods,
789/// URLs, payload bounds, or signing failures.
790pub fn create_nip98_authorization(
791    identity: &LocalIdentity,
792    method: &str,
793    url: &str,
794    payload: Option<&[u8]>,
795    created_at: u64,
796) -> Result<HttpAuthorizationPlan, SoftchatError> {
797    if payload.is_some_and(|value| value.len() > MAX_HTTP_AUTHORIZATION_PAYLOAD_BYTES) {
798        return Err(SoftchatError::InvalidHttpAuthorization);
799    }
800    let method = validate_http_method(method)?;
801    let url = validate_http_url(url)?;
802    let payload_sha256 = payload.map(|bytes| hex::encode(Sha256::digest(bytes)));
803    create_nip98_authorization_for_validated_hash(identity, method, url, payload_sha256, created_at)
804}
805
806/// Create a NIP-98 plan from a SHA-256 produced while streaming a request body.
807///
808/// This is the production attachment-upload path: platform HTTP/file code
809/// streams the body, then passes its lowercase hexadecimal digest without
810/// copying the full payload across FFI.
811///
812/// # Errors
813///
814/// Returns [`SoftchatError::InvalidHttpAuthorization`] for an invalid method,
815/// URL, digest, timestamp, or signing failure.
816pub fn create_nip98_authorization_for_payload_hash(
817    identity: &LocalIdentity,
818    method: &str,
819    url: &str,
820    payload_sha256: &str,
821    created_at: u64,
822) -> Result<HttpAuthorizationPlan, SoftchatError> {
823    let method = validate_http_method(method)?;
824    let url = validate_http_url(url)?;
825    validate_sha256(payload_sha256).map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
826    create_nip98_authorization_for_validated_hash(
827        identity,
828        method,
829        url,
830        Some(payload_sha256.to_owned()),
831        created_at,
832    )
833}
834
835fn create_nip98_authorization_for_validated_hash(
836    identity: &LocalIdentity,
837    method: String,
838    url: Url,
839    payload_sha256: Option<String>,
840    created_at: u64,
841) -> Result<HttpAuthorizationPlan, SoftchatError> {
842    let mut tags = vec![
843        NostrTag::new(vec!["u", url.as_str()])
844            .map_err(|_| SoftchatError::InvalidHttpAuthorization)?,
845        NostrTag::new(vec!["method", method.as_str()])
846            .map_err(|_| SoftchatError::InvalidHttpAuthorization)?,
847    ];
848    if let Some(hash) = &payload_sha256 {
849        tags.push(
850            NostrTag::new(vec!["payload", hash.as_str()])
851                .map_err(|_| SoftchatError::InvalidHttpAuthorization)?,
852        );
853    }
854    let event = identity
855        .sign_event(
856            NostrEventDraft::new(created_at, NostrEventKind::HTTP_AUTHENTICATION, tags, "")
857                .map_err(|_| SoftchatError::InvalidHttpAuthorization)?,
858        )
859        .map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
860    let event_json = event
861        .to_json()
862        .map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
863    Ok(HttpAuthorizationPlan {
864        method,
865        url: url.to_string(),
866        payload_sha256: payload_sha256.unwrap_or_default(),
867        authorization_header: format!("Nostr {}", STANDARD.encode(event_json)),
868        event,
869        allow_authorization_on_redirect: false,
870    })
871}
872
873fn split_field(raw: &str) -> Result<(&str, &str), SoftchatError> {
874    let (key, value) = raw
875        .split_once(' ')
876        .ok_or(SoftchatError::InvalidAttachmentMetadata)?;
877    if key.is_empty() || value.is_empty() {
878        return Err(SoftchatError::InvalidAttachmentMetadata);
879    }
880    Ok((key, value))
881}
882
883fn is_known_singleton(key: &str) -> bool {
884    matches!(
885        key,
886        "url"
887            | "m"
888            | "x"
889            | "ox"
890            | "size"
891            | "dim"
892            | "name"
893            | "magnet"
894            | "i"
895            | "blurhash"
896            | "thumb"
897            | "image"
898            | "summary"
899            | "alt"
900            | "service"
901            | "enc"
902    )
903}
904
905fn validate_https_url(value: &str) -> Result<(), SoftchatError> {
906    let url = Url::parse(value).map_err(|_| SoftchatError::InvalidAttachmentMetadata)?;
907    if url.scheme() != "https"
908        || url.host_str().is_none()
909        || !url.username().is_empty()
910        || url.password().is_some()
911        || url.fragment().is_some()
912    {
913        return Err(SoftchatError::InvalidAttachmentMetadata);
914    }
915    Ok(())
916}
917
918fn validate_mime(value: &str) -> Result<(), SoftchatError> {
919    if value.is_empty()
920        || value.bytes().any(|byte| byte.is_ascii_uppercase())
921        || !value.contains('/')
922        || value.chars().any(char::is_whitespace)
923    {
924        return Err(SoftchatError::InvalidAttachmentMetadata);
925    }
926    Ok(())
927}
928
929fn validate_sha256(value: &str) -> Result<(), SoftchatError> {
930    if value.len() != 64
931        || !value
932            .bytes()
933            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
934    {
935        return Err(SoftchatError::InvalidAttachmentMetadata);
936    }
937    Ok(())
938}
939
940fn validate_dimensions(value: &str) -> Result<(), SoftchatError> {
941    let (width, height) = value
942        .split_once('x')
943        .ok_or(SoftchatError::InvalidAttachmentMetadata)?;
944    let width: u32 = width
945        .parse()
946        .map_err(|_| SoftchatError::InvalidAttachmentMetadata)?;
947    let height: u32 = height
948        .parse()
949        .map_err(|_| SoftchatError::InvalidAttachmentMetadata)?;
950    if width == 0 || height == 0 {
951        return Err(SoftchatError::InvalidAttachmentMetadata);
952    }
953    Ok(())
954}
955
956fn push_optional(values: &mut Vec<String>, key: &str, value: &Option<String>) {
957    if let Some(value) = value {
958        values.push(format!("{key} {value}"));
959    }
960}
961
962fn validate_attachment_input(
963    plaintext_length: usize,
964    key_length: usize,
965) -> Result<(), SoftchatError> {
966    if key_length != ATTACHMENT_KEY_BYTES {
967        return Err(SoftchatError::AttachmentEncryptionFailed);
968    }
969    if plaintext_length > MAX_ATTACHMENT_COMPATIBILITY_BYTES {
970        return Err(SoftchatError::AttachmentTooLarge);
971    }
972    Ok(())
973}
974
975fn validate_http_method(method: &str) -> Result<String, SoftchatError> {
976    if method.is_empty()
977        || method.len() > 32
978        || !method.bytes().all(|byte| byte.is_ascii_alphabetic())
979    {
980        return Err(SoftchatError::InvalidHttpAuthorization);
981    }
982    Ok(method.to_ascii_uppercase())
983}
984
985fn validate_http_url(value: &str) -> Result<Url, SoftchatError> {
986    if value.is_empty() || value.len() > MAX_HTTP_AUTHORIZATION_URL_BYTES {
987        return Err(SoftchatError::InvalidHttpAuthorization);
988    }
989    let url = Url::parse(value).map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
990    if !matches!(url.scheme(), "http" | "https")
991        || url.host_str().is_none()
992        || url.username() != ""
993        || url.password().is_some()
994        || url.fragment().is_some()
995    {
996        return Err(SoftchatError::InvalidHttpAuthorization);
997    }
998    Ok(url)
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
1006
1007    #[test]
1008    fn parses_released_attachment_and_emits_canonical_order() -> Result<(), SoftchatError> {
1009        let raw = vec![
1010            "imeta".to_owned(),
1011            "url https://cdn.example/voice.m4a".to_owned(),
1012            "m audio/mp4".to_owned(),
1013            "name voice note.m4a".to_owned(),
1014            "thumb https://cdn.example/voice.webp".to_owned(),
1015            "enc v1:synthetic".to_owned(),
1016            "future retained value".to_owned(),
1017        ];
1018        let metadata = AttachmentMetadata::from_tag(&raw)?;
1019        assert_eq!(metadata.name.as_deref(), Some("voice note.m4a"));
1020        assert_eq!(
1021            metadata.to_tag()?.values(),
1022            [
1023                "imeta",
1024                "url https://cdn.example/voice.m4a",
1025                "m audio/mp4",
1026                "name voice note.m4a",
1027                "thumb https://cdn.example/voice.webp",
1028                "enc v1:synthetic",
1029                "future retained value",
1030            ]
1031        );
1032        Ok(())
1033    }
1034
1035    #[test]
1036    fn attachment_metadata_rejects_duplicates_and_invalid_typed_fields() {
1037        for values in [
1038            vec!["imeta", "url https://a.example", "url https://b.example"],
1039            vec!["imeta", "url http://a.example"],
1040            vec!["imeta", "url https://[email protected]/file"],
1041            vec!["imeta", "url https://user:[email protected]/file"],
1042            vec!["imeta", "url https://a.example/file#fragment"],
1043            vec!["imeta", "url https://a.example", "m Image/PNG"],
1044            vec!["imeta", "url https://a.example", "dim 0x1"],
1045            vec!["imeta", "url https://a.example", "x ab"],
1046        ] {
1047            let values = values.into_iter().map(str::to_owned).collect::<Vec<_>>();
1048            assert!(AttachmentMetadata::from_tag(&values).is_err());
1049        }
1050    }
1051
1052    #[test]
1053    fn attachment_crypto_matches_combined_shape_and_authenticates() -> Result<(), SoftchatError> {
1054        let key = (0_u8..32).collect::<Vec<_>>();
1055        let plaintext = b"authenticated attachment".to_vec();
1056        let encrypted = encrypt_attachment_bytes(plaintext.clone(), key.clone())?;
1057        assert_eq!(
1058            encrypted.len(),
1059            plaintext.len() + ATTACHMENT_NONCE_BYTES + ATTACHMENT_TAG_BYTES
1060        );
1061        assert_eq!(decrypt_attachment_bytes(encrypted.clone(), key)?, plaintext);
1062        let mut tampered = encrypted;
1063        if let Some(last) = tampered.last_mut() {
1064            *last ^= 1;
1065        }
1066        assert!(decrypt_attachment_bytes(tampered, vec![0; 32]).is_err());
1067        Ok(())
1068    }
1069
1070    #[test]
1071    fn streaming_attachment_crypto_matches_released_combined_format() -> Result<(), SoftchatError> {
1072        let key = vec![0x42; ATTACHMENT_KEY_BYTES];
1073        let nonce = [0x24; ATTACHMENT_NONCE_BYTES];
1074        let plaintext = (0..131_111)
1075            .map(|index| (index % 251) as u8)
1076            .collect::<Vec<_>>();
1077
1078        let expected_ciphertext = ChaCha20Poly1305::new(Key::from_slice(&key))
1079            .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref())
1080            .map_err(|_| SoftchatError::AttachmentEncryptionFailed)?;
1081        let expected_split = expected_ciphertext.len() - ATTACHMENT_TAG_BYTES;
1082
1083        let encryptor = AttachmentEncryptionStream::new_with_nonce(key.clone(), nonce)?;
1084        let mut ciphertext = Vec::new();
1085        for chunk in plaintext.chunks(997) {
1086            ciphertext.extend(encryptor.update(chunk.to_vec())?);
1087        }
1088        let encrypted_final = encryptor.finish()?;
1089        assert_eq!(ciphertext, expected_ciphertext[..expected_split]);
1090        assert_eq!(
1091            encrypted_final.authentication_tag,
1092            expected_ciphertext[expected_split..]
1093        );
1094        assert_eq!(
1095            encrypted_final.plaintext_sha256,
1096            hex::encode(Sha256::digest(&plaintext))
1097        );
1098
1099        let decryptor = AttachmentDecryptionStream::new(key.clone(), nonce.to_vec())?;
1100        let mut decrypted = Vec::new();
1101        for chunk in ciphertext.chunks(509) {
1102            decrypted.extend(decryptor.update(chunk.to_vec())?);
1103        }
1104        let decrypted_final = decryptor.finish(expected_ciphertext[expected_split..].to_vec())?;
1105        assert_eq!(decrypted, plaintext);
1106        assert_eq!(
1107            decrypted_final.encrypted_sha256,
1108            encrypted_final.encrypted_sha256
1109        );
1110
1111        let tampered = AttachmentDecryptionStream::new(key, nonce.to_vec())?;
1112        let _staged_plaintext = tampered.update(ciphertext)?;
1113        let mut bad_tag = expected_ciphertext[expected_split..].to_vec();
1114        bad_tag[0] ^= 1;
1115        assert!(matches!(
1116            tampered.finish(bad_tag),
1117            Err(SoftchatError::AttachmentDecryptionFailed)
1118        ));
1119        Ok(())
1120    }
1121
1122    #[test]
1123    fn streaming_attachment_crypto_supports_empty_files_and_cancellation()
1124    -> Result<(), SoftchatError> {
1125        let key = vec![0x55; ATTACHMENT_KEY_BYTES];
1126        let nonce = [0x11; ATTACHMENT_NONCE_BYTES];
1127        let expected = ChaCha20Poly1305::new(Key::from_slice(&key))
1128            .encrypt(Nonce::from_slice(&nonce), [].as_ref())
1129            .map_err(|_| SoftchatError::AttachmentEncryptionFailed)?;
1130        let encryptor = AttachmentEncryptionStream::new_with_nonce(key.clone(), nonce)?;
1131        assert_eq!(encryptor.finish()?.authentication_tag, expected);
1132
1133        let cancelled = AttachmentEncryptionStream::new(key)?;
1134        cancelled.cancel();
1135        assert!(matches!(
1136            cancelled.update(vec![1]),
1137            Err(SoftchatError::AttachmentEncryptionFailed)
1138        ));
1139        Ok(())
1140    }
1141
1142    #[test]
1143    fn random_stream_descriptor_round_trips_without_exporting_raw_key() -> Result<(), SoftchatError>
1144    {
1145        let plaintext = b"descriptor-owned key".to_vec();
1146        let encryptor = AttachmentEncryptionStream::random()?;
1147        let descriptor = encryptor.encryption_descriptor();
1148        assert_eq!(
1149            STANDARD
1150                .decode(&descriptor)
1151                .map_err(|_| SoftchatError::AttachmentEncryptionFailed)?
1152                .len(),
1153            ATTACHMENT_KEY_BYTES
1154        );
1155        let nonce = encryptor.nonce();
1156        let ciphertext = encryptor.update(plaintext.clone())?;
1157        let final_value = encryptor.finish()?;
1158
1159        let decryptor = AttachmentDecryptionStream::from_encryption_descriptor(descriptor, nonce)?;
1160        assert_eq!(decryptor.update(ciphertext)?, plaintext);
1161        decryptor.finish(final_value.authentication_tag)?;
1162        assert!(
1163            AttachmentDecryptionStream::from_encryption_descriptor(
1164                "not-base64".to_owned(),
1165                vec![0; ATTACHMENT_NONCE_BYTES],
1166            )
1167            .is_err()
1168        );
1169        Ok(())
1170    }
1171
1172    #[test]
1173    fn nip98_plan_binds_method_url_payload_and_forbids_redirect_reuse() -> Result<(), SoftchatError>
1174    {
1175        let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1176        let plan = create_nip98_authorization(
1177            &identity,
1178            "PUT",
1179            "https://files.example/upload?part=1",
1180            Some(b"payload"),
1181            1_700_000_000,
1182        )?;
1183        assert_eq!(plan.method, "PUT");
1184        assert_eq!(plan.payload_sha256, hex::encode(Sha256::digest(b"payload")));
1185        assert!(plan.authorization_header.starts_with("Nostr "));
1186        assert!(!plan.allow_authorization_on_redirect);
1187        assert_eq!(plan.event.kind(), NostrEventKind::HTTP_AUTHENTICATION);
1188        assert!(plan.event.tags().any(|tag| tag == ["method", "PUT"]));
1189        let streamed = create_nip98_authorization_for_payload_hash(
1190            &identity,
1191            "PUT",
1192            "https://files.example/upload?part=1",
1193            &plan.payload_sha256,
1194            1_700_000_001,
1195        )?;
1196        assert_eq!(streamed.payload_sha256, plan.payload_sha256);
1197        assert!(
1198            streamed
1199                .event
1200                .tags()
1201                .any(|tag| tag == ["payload", plan.payload_sha256.as_str()])
1202        );
1203        assert!(
1204            create_nip98_authorization_for_payload_hash(
1205                &identity,
1206                "PUT",
1207                "https://files.example/upload",
1208                "not-a-digest",
1209                1,
1210            )
1211            .is_err()
1212        );
1213        assert!(
1214            create_nip98_authorization(&identity, "P UT", "https://files.example/upload", None, 1,)
1215                .is_err()
1216        );
1217        assert!(
1218            create_nip98_authorization(
1219                &identity,
1220                "PUT",
1221                "https://files.example/upload",
1222                Some(&vec![0; MAX_HTTP_AUTHORIZATION_PAYLOAD_BYTES + 1]),
1223                1,
1224            )
1225            .is_err()
1226        );
1227        assert!(
1228            create_nip98_authorization(
1229                &identity,
1230                "PUT",
1231                &format!(
1232                    "https://files.example/{}",
1233                    "x".repeat(MAX_HTTP_AUTHORIZATION_URL_BYTES)
1234                ),
1235                None,
1236                1,
1237            )
1238            .is_err()
1239        );
1240        Ok(())
1241    }
1242}