1use 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
25pub const MAX_ATTACHMENT_METADATA_FIELDS: usize = 128;
27pub const MAX_ATTACHMENT_FALLBACKS: usize = 16;
29pub const MAX_ATTACHMENT_COMPATIBILITY_BYTES: usize = 16 * 1024 * 1024;
34pub const MAX_ATTACHMENT_STREAM_BYTES: u64 = 16 * 1024 * 1024 * 1024;
36pub const MAX_ATTACHMENT_STREAM_CHUNK_BYTES: usize = 1024 * 1024;
38pub const MAX_HTTP_AUTHORIZATION_URL_BYTES: usize = 8 * 1024;
40pub 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#[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 pub url: String,
53 pub mime_type: Option<String>,
55 pub sha256: Option<String>,
57 pub original_sha256: Option<String>,
59 pub byte_size: Option<u64>,
61 pub dimensions: Option<String>,
63 pub name: Option<String>,
65 pub magnet: Option<String>,
67 pub infohash: Option<String>,
69 pub blurhash: Option<String>,
71 pub thumbnail_url: Option<String>,
73 pub image_url: Option<String>,
75 pub summary: Option<String>,
77 pub alt: Option<String>,
79 pub fallback_urls: Vec<String>,
81 pub service: Option<String>,
83 pub encryption: Option<String>,
85 pub unknown_fields: Vec<String>,
87}
88
89impl AttachmentMetadata {
90 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 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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
274#[serde(rename_all = "camelCase")]
275#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
276pub struct AttachmentStreamFinal {
277 pub authentication_tag: Vec<u8>,
279 pub plaintext_sha256: String,
281 pub encrypted_sha256: String,
283 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#[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 #[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 #[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 #[must_use]
455 pub fn encryption_descriptor(&self) -> String {
456 self.encryption_descriptor.clone()
457 }
458
459 #[must_use]
461 pub fn nonce(&self) -> Vec<u8> {
462 self.nonce.to_vec()
463 }
464
465 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 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 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#[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 #[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 #[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 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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
651pub struct HttpAuthorizationPlan {
652 pub method: String,
654 pub url: String,
656 pub payload_sha256: String,
658 pub authorization_header: String,
660 pub event: crate::SignedNostrEvent,
662 pub allow_authorization_on_redirect: bool,
664}
665
666#[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 pub method: String,
683 pub url: String,
685 pub payload_sha256: String,
687 pub authorization_header: String,
689 pub event: crate::event::SignedEvent,
691 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#[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#[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#[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
780pub 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
806pub 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}