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;
38const ATTACHMENT_KEY_BYTES: usize = 32;
39const ATTACHMENT_NONCE_BYTES: usize = 12;
40const ATTACHMENT_TAG_BYTES: usize = 16;
41
42#[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 pub url: String,
49 pub mime_type: Option<String>,
51 pub sha256: Option<String>,
53 pub original_sha256: Option<String>,
55 pub byte_size: Option<u64>,
57 pub dimensions: Option<String>,
59 pub name: Option<String>,
61 pub magnet: Option<String>,
63 pub infohash: Option<String>,
65 pub blurhash: Option<String>,
67 pub thumbnail_url: Option<String>,
69 pub image_url: Option<String>,
71 pub summary: Option<String>,
73 pub alt: Option<String>,
75 pub fallback_urls: Vec<String>,
77 pub service: Option<String>,
79 pub encryption: Option<String>,
81 pub unknown_fields: Vec<String>,
83}
84
85impl AttachmentMetadata {
86 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 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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
270#[serde(rename_all = "camelCase")]
271#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
272pub struct AttachmentStreamFinal {
273 pub authentication_tag: Vec<u8>,
275 pub plaintext_sha256: String,
277 pub encrypted_sha256: String,
279 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#[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 #[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 #[must_use]
426 pub fn nonce(&self) -> Vec<u8> {
427 self.nonce.to_vec()
428 }
429
430 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 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 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#[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 #[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 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 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 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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
585#[serde(rename_all = "camelCase")]
586#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
587pub struct HttpAuthorizationPlan {
588 pub method: String,
590 pub url: String,
592 pub payload_sha256: String,
594 pub authorization_header: String,
596 pub event: crate::SignedEvent,
598 pub allow_authorization_on_redirect: bool,
600}
601
602#[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#[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#[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
668pub 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}