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