Skip to main content

softchat/
message.rs

1use std::fmt;
2use std::sync::{Arc, RwLock};
3
4#[cfg(all(feature = "android-jni", target_os = "android"))]
5use std::collections::HashMap;
6#[cfg(all(feature = "android-jni", target_os = "android"))]
7use std::sync::atomic::{AtomicU64, Ordering};
8#[cfg(all(feature = "android-jni", target_os = "android"))]
9use std::sync::{OnceLock, Weak};
10
11use crate::{
12    EventDraft, LocalIdentity, NIP44_V2_ALGORITHM, Nip17Message, Nip44EncryptedMessage,
13    Nip44Payload, Nip59EnvelopeKind, NostrEventDraft, NostrPublicKey, NostrRumor, RumorEvent,
14    SignedEvent, SignedNostrEvent, SoftchatError, UnwrappedEnvelope,
15};
16
17/// A Phase 1 encrypted-message binding value.
18///
19/// This record is not a Nostr event, NIP-59 gift wrap, or accepted persisted
20/// Softchat schema.
21#[derive(Clone, Eq, PartialEq)]
22#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
23pub struct EncryptedMessage {
24    /// Encryption algorithm identifier.
25    pub algorithm: String,
26    /// Lowercase hexadecimal x-only public key of the sender.
27    pub sender_public_key: String,
28    /// Standard base64 NIP-44 v2 payload.
29    pub payload: String,
30}
31
32impl fmt::Debug for EncryptedMessage {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter
35            .debug_struct("EncryptedMessage")
36            .field("algorithm", &self.algorithm)
37            .field("sender_public_key", &self.sender_public_key)
38            .field("payload_bytes", &self.payload.len())
39            .finish()
40    }
41}
42
43impl From<Nip44EncryptedMessage> for EncryptedMessage {
44    fn from(message: Nip44EncryptedMessage) -> Self {
45        let (sender_public_key, payload) = message.into_parts();
46        Self {
47            algorithm: NIP44_V2_ALGORITHM.to_owned(),
48            sender_public_key: sender_public_key.to_hex(),
49            payload: payload.into_string(),
50        }
51    }
52}
53
54impl TryFrom<EncryptedMessage> for Nip44EncryptedMessage {
55    type Error = SoftchatError;
56
57    fn try_from(message: EncryptedMessage) -> Result<Self, Self::Error> {
58        if message.algorithm != NIP44_V2_ALGORITHM {
59            return Err(SoftchatError::UnsupportedAlgorithm);
60        }
61
62        let payload = Nip44Payload::from_encoded(message.payload)?;
63        let sender_public_key = NostrPublicKey::from_hex(&message.sender_public_key)?;
64        Ok(Self::new(sender_public_key, payload))
65    }
66}
67
68/// Opaque, explicitly erasable identity used by native bindings.
69///
70/// The inner identity permits concurrent read operations. Erasing waits for
71/// in-flight operations, removes the held secret through normal drop, and
72/// makes every later operation fail with [`SoftchatError::IdentityErased`].
73#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
74pub struct LocalIdentityHandle {
75    state: HandleIdentityState,
76    public_key: String,
77    fast_handle_id: u64,
78}
79
80/// One-call native import result used by authored foreign-language facades.
81#[derive(Clone, Debug)]
82#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
83pub struct ImportedLocalIdentity {
84    /// Opaque identity authority retained by the authored facade.
85    pub handle: Arc<LocalIdentityHandle>,
86    /// Canonical public key derived during import.
87    pub public_key: String,
88    /// Opaque process-local ID used only by the Android bulk-data bridge.
89    pub fast_handle_id: u64,
90}
91
92#[derive(Debug)]
93struct IdentityState {
94    identity: RwLock<Option<LocalIdentity>>,
95}
96
97#[cfg(all(feature = "android-jni", target_os = "android"))]
98type HandleIdentityState = Arc<IdentityState>;
99#[cfg(not(all(feature = "android-jni", target_os = "android")))]
100type HandleIdentityState = IdentityState;
101
102#[cfg(all(feature = "android-jni", target_os = "android"))]
103static NEXT_FAST_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
104#[cfg(all(feature = "android-jni", target_os = "android"))]
105static FAST_HANDLE_REGISTRY: OnceLock<RwLock<HashMap<u64, Weak<IdentityState>>>> = OnceLock::new();
106
107#[cfg_attr(feature = "native-bindings", uniffi::export)]
108impl LocalIdentityHandle {
109    /// Import a secret key from exactly 32 bytes.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`SoftchatError::InvalidSecretKey`] for an invalid scalar.
114    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
115    pub fn new(mut secret_key_bytes: Vec<u8>) -> Result<Self, SoftchatError> {
116        let identity = LocalIdentity::from_secret_bytes(&secret_key_bytes);
117        secret_key_bytes.fill(0);
118        let identity = identity?;
119        let public_key = identity.public_key().to_hex();
120
121        let state = IdentityState {
122            identity: RwLock::new(Some(identity)),
123        };
124        #[cfg(all(feature = "android-jni", target_os = "android"))]
125        let state = Arc::new(state);
126        let fast_handle_id = register_fast_handle(&state);
127        Ok(Self {
128            state,
129            public_key,
130            fast_handle_id,
131        })
132    }
133
134    /// Return this identity's canonical public key.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`SoftchatError::IdentityErased`] after [`Self::erase`].
139    pub fn public_key(&self) -> Result<String, SoftchatError> {
140        self.with_identity(|_| Ok(self.public_key.clone()))
141    }
142
143    /// Encrypt a UTF-8 message for a validated public key.
144    ///
145    /// # Errors
146    ///
147    /// Returns a stable validation, lifecycle, or redacted encryption failure.
148    pub fn encrypt_message(
149        &self,
150        recipient_public_key_hex: String,
151        plaintext: String,
152    ) -> Result<EncryptedMessage, SoftchatError> {
153        let recipient = NostrPublicKey::from_hex(&recipient_public_key_hex)?;
154        self.with_identity(|identity| {
155            identity
156                .encrypt_utf8(&recipient, &plaintext)
157                .map(EncryptedMessage::from)
158        })
159    }
160
161    /// Encrypt and return only the encoded payload for zero-copy record
162    /// assembly in authored native-language facades.
163    ///
164    /// The caller already owns the constant algorithm identifier and this
165    /// handle's public key. Returning the large payload directly avoids
166    /// serializing it into a second aggregate binding buffer.
167    ///
168    /// # Errors
169    ///
170    /// Returns a stable validation, lifecycle, or redacted encryption failure.
171    #[doc(hidden)]
172    pub fn encrypt_payload(
173        &self,
174        recipient_public_key_hex: String,
175        plaintext: String,
176    ) -> Result<String, SoftchatError> {
177        let recipient = NostrPublicKey::from_hex(&recipient_public_key_hex)?;
178        self.with_identity(|identity| {
179            identity
180                .encrypt_utf8(&recipient, &plaintext)
181                .map(|message| message.payload().as_str().to_owned())
182        })
183    }
184
185    /// Authenticate and decrypt one binding message.
186    ///
187    /// # Errors
188    ///
189    /// Returns a stable validation, lifecycle, or redacted decryption failure.
190    pub fn decrypt_message(&self, message: EncryptedMessage) -> Result<String, SoftchatError> {
191        let message = Nip44EncryptedMessage::try_from(message)?;
192        self.with_identity(|identity| identity.decrypt_utf8(&message))
193    }
194
195    /// Authenticate and decrypt direct sender/payload strings.
196    ///
197    /// This is the authored-facade fast path. It avoids copying a large
198    /// ciphertext into and back out of an aggregate binding record.
199    ///
200    /// # Errors
201    ///
202    /// Returns a stable validation, lifecycle, resource-bound, or redacted
203    /// decryption failure.
204    #[doc(hidden)]
205    pub fn decrypt_payload(
206        &self,
207        sender_public_key_hex: String,
208        payload: String,
209    ) -> Result<String, SoftchatError> {
210        let sender_public_key = NostrPublicKey::from_hex(&sender_public_key_hex)?;
211        let payload = Nip44Payload::from_encoded(payload)?;
212        let message = Nip44EncryptedMessage::new(sender_public_key, payload);
213        self.with_identity(|identity| identity.decrypt_utf8(&message))
214    }
215
216    /// Sign one validated, explicit NIP-01 event draft.
217    ///
218    /// # Errors
219    ///
220    /// Returns a stable draft-validation, lifecycle, size, or signing failure.
221    pub fn sign_event(&self, draft: EventDraft) -> Result<SignedEvent, SoftchatError> {
222        let draft = NostrEventDraft::try_from(draft)?;
223        self.with_identity(|identity| {
224            identity
225                .sign_event(draft)
226                .map(|event| SignedEvent::from(&event))
227        })
228    }
229
230    /// Create one unsigned rumor authored by this identity.
231    ///
232    /// # Errors
233    ///
234    /// Returns a stable draft-validation, lifecycle, or event-size failure.
235    pub fn create_rumor(&self, draft: EventDraft) -> Result<RumorEvent, SoftchatError> {
236        let draft = NostrEventDraft::try_from(draft)?;
237        self.with_identity(|identity| {
238            identity
239                .create_rumor(draft)
240                .map(|rumor| RumorEvent::from(&rumor))
241        })
242    }
243
244    /// Create one minimal one-to-one NIP-17 text-message rumor.
245    ///
246    /// # Errors
247    ///
248    /// Returns a stable lifecycle or redacted message-creation failure.
249    pub fn create_nip17_text_message(
250        &self,
251        recipient_public_key_hex: String,
252        created_at: i64,
253        content: String,
254    ) -> Result<Nip17Message, SoftchatError> {
255        self.with_identity(|identity| {
256            let recipient = NostrPublicKey::from_hex(&recipient_public_key_hex)
257                .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
258            let created_at =
259                u64::try_from(created_at).map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
260            identity
261                .create_nip17_text_message(&recipient, created_at, content)
262                .map(|message| Nip17Message::from(&message))
263        })
264    }
265
266    /// Create one fresh NIP-59 recipient envelope.
267    ///
268    /// # Errors
269    ///
270    /// Returns a stable lifecycle or redacted envelope-creation failure.
271    pub fn gift_wrap(
272        &self,
273        recipient_public_key_hex: String,
274        rumor: RumorEvent,
275        kind: Nip59EnvelopeKind,
276        now: i64,
277    ) -> Result<SignedEvent, SoftchatError> {
278        self.with_identity(|identity| {
279            let recipient = NostrPublicKey::from_hex(&recipient_public_key_hex)
280                .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
281            let rumor = NostrRumor::try_from(rumor)
282                .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
283            let now = u64::try_from(now).map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
284            identity
285                .gift_wrap(&recipient, &rumor, kind, now)
286                .map(|event| SignedEvent::from(&event))
287        })
288    }
289
290    /// Authenticate and unwrap one NIP-59 recipient envelope.
291    ///
292    /// # Errors
293    ///
294    /// Returns a stable lifecycle or redacted invalid-envelope failure.
295    pub fn unwrap_gift_wrap(&self, outer: SignedEvent) -> Result<UnwrappedEnvelope, SoftchatError> {
296        self.with_identity(|identity| {
297            let outer = SignedNostrEvent::try_from(outer)
298                .map_err(|_| SoftchatError::InvalidNip59Envelope)?;
299            identity
300                .unwrap_gift_wrap(&outer)
301                .map(UnwrappedEnvelope::from)
302        })
303    }
304
305    /// Irreversibly remove this handle's secret-bearing identity.
306    ///
307    /// The operation is idempotent. Calls already in flight complete before
308    /// the secret is dropped.
309    pub fn erase(&self) {
310        let mut guard = match self.state.identity.write() {
311            Ok(guard) => guard,
312            Err(poisoned) => poisoned.into_inner(),
313        };
314        guard.take();
315    }
316}
317
318/// Import a local identity and return its non-secret cached values in one
319/// generated-binding call.
320///
321/// # Errors
322///
323/// Returns [`SoftchatError::InvalidSecretKey`] for an invalid scalar.
324#[cfg_attr(feature = "native-bindings", uniffi::export)]
325#[doc(hidden)]
326pub fn import_local_identity(
327    secret_key_bytes: Vec<u8>,
328) -> Result<ImportedLocalIdentity, SoftchatError> {
329    let handle = Arc::new(LocalIdentityHandle::new(secret_key_bytes)?);
330    Ok(ImportedLocalIdentity {
331        public_key: handle.public_key.clone(),
332        fast_handle_id: handle.fast_handle_id,
333        handle,
334    })
335}
336
337impl LocalIdentityHandle {
338    pub(crate) fn with_identity<T>(
339        &self,
340        operation: impl FnOnce(&LocalIdentity) -> Result<T, SoftchatError>,
341    ) -> Result<T, SoftchatError> {
342        let guard = match self.state.identity.read() {
343            Ok(guard) => guard,
344            Err(poisoned) => poisoned.into_inner(),
345        };
346        let identity = guard.as_ref().ok_or(SoftchatError::IdentityErased)?;
347        operation(identity)
348    }
349}
350
351impl Drop for LocalIdentityHandle {
352    fn drop(&mut self) {
353        #[cfg(all(feature = "android-jni", target_os = "android"))]
354        {
355            let registry = fast_handle_registry();
356            let mut guard = match registry.write() {
357                Ok(guard) => guard,
358                Err(poisoned) => poisoned.into_inner(),
359            };
360            guard.remove(&self.fast_handle_id);
361        }
362    }
363}
364
365impl fmt::Debug for LocalIdentityHandle {
366    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
367        let guard = match self.state.identity.read() {
368            Ok(guard) => guard,
369            Err(poisoned) => poisoned.into_inner(),
370        };
371        formatter
372            .debug_struct("LocalIdentityHandle")
373            .field("erased", &guard.is_none())
374            .field("secret_key", &"[redacted]")
375            .finish()
376    }
377}
378
379#[cfg(all(feature = "android-jni", target_os = "android"))]
380fn fast_handle_registry() -> &'static RwLock<HashMap<u64, Weak<IdentityState>>> {
381    FAST_HANDLE_REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
382}
383
384#[cfg(all(feature = "android-jni", target_os = "android"))]
385fn register_fast_handle(state: &Arc<IdentityState>) -> u64 {
386    let registry = fast_handle_registry();
387    let mut guard = match registry.write() {
388        Ok(guard) => guard,
389        Err(poisoned) => poisoned.into_inner(),
390    };
391    loop {
392        let candidate = NEXT_FAST_HANDLE_ID.fetch_add(1, Ordering::Relaxed);
393        if candidate != 0 && !guard.contains_key(&candidate) {
394            guard.insert(candidate, Arc::downgrade(state));
395            return candidate;
396        }
397    }
398}
399
400#[cfg(not(all(feature = "android-jni", target_os = "android")))]
401fn register_fast_handle(_state: &IdentityState) -> u64 {
402    0
403}
404
405#[cfg(all(feature = "android-jni", target_os = "android"))]
406pub(crate) fn with_fast_identity<T>(
407    fast_handle_id: u64,
408    operation: impl FnOnce(&LocalIdentity) -> Result<T, SoftchatError>,
409) -> Result<T, SoftchatError> {
410    let state = {
411        let registry = fast_handle_registry();
412        let guard = match registry.read() {
413            Ok(guard) => guard,
414            Err(poisoned) => poisoned.into_inner(),
415        };
416        guard
417            .get(&fast_handle_id)
418            .and_then(Weak::upgrade)
419            .ok_or(SoftchatError::IdentityErased)?
420    };
421    let guard = match state.identity.read() {
422        Ok(guard) => guard,
423        Err(poisoned) => poisoned.into_inner(),
424    };
425    let identity = guard.as_ref().ok_or(SoftchatError::IdentityErased)?;
426    operation(identity)
427}
428
429/// Return the Softchat semantic version compiled into the library.
430#[cfg_attr(feature = "native-bindings", uniffi::export)]
431#[must_use]
432pub fn version() -> String {
433    env!("CARGO_PKG_VERSION").to_owned()
434}
435
436/// Derive a lowercase hexadecimal Nostr public key from a secret key.
437///
438/// This compatibility function retains the original string-based binding
439/// shape. New foreign-language callers should import one identity capability.
440///
441/// # Errors
442///
443/// Returns [`SoftchatError::InvalidSecretKey`] for an invalid scalar.
444#[cfg_attr(feature = "native-bindings", uniffi::export)]
445pub fn public_key(secret_key_hex: String) -> Result<String, SoftchatError> {
446    Ok(LocalIdentity::from_secret_hex(&secret_key_hex)?
447        .public_key()
448        .to_hex())
449}
450
451/// Validate and canonicalize one Nostr public key.
452///
453/// # Errors
454///
455/// Returns [`SoftchatError::InvalidPublicKey`] for an invalid value.
456#[cfg_attr(feature = "native-bindings", uniffi::export)]
457pub fn validate_public_key(public_key_hex: String) -> Result<String, SoftchatError> {
458    Ok(NostrPublicKey::from_hex(&public_key_hex)?.to_hex())
459}
460
461/// Validate and canonicalize one received binding message.
462///
463/// Cryptographic authentication remains part of decryption.
464///
465/// # Errors
466///
467/// Returns a stable algorithm, key, or resource-bound failure.
468#[cfg_attr(feature = "native-bindings", uniffi::export)]
469pub fn validate_encrypted_message(
470    message: EncryptedMessage,
471) -> Result<EncryptedMessage, SoftchatError> {
472    Nip44EncryptedMessage::try_from(message).map(EncryptedMessage::from)
473}
474
475/// Encrypt a UTF-8 message with the legacy string-based facade.
476///
477/// # Errors
478///
479/// Returns a stable validation or redacted encryption failure.
480#[cfg_attr(feature = "native-bindings", uniffi::export)]
481pub fn encrypt_message(
482    sender_secret_key_hex: String,
483    recipient_public_key_hex: String,
484    plaintext: String,
485) -> Result<EncryptedMessage, SoftchatError> {
486    let identity = LocalIdentity::from_secret_hex(&sender_secret_key_hex)?;
487    let recipient = NostrPublicKey::from_hex(&recipient_public_key_hex)?;
488    identity
489        .encrypt_utf8(&recipient, &plaintext)
490        .map(EncryptedMessage::from)
491}
492
493/// Decrypt with the legacy string-based facade.
494///
495/// # Errors
496///
497/// Returns a stable validation or redacted decryption failure.
498#[cfg_attr(feature = "native-bindings", uniffi::export)]
499pub fn decrypt_message(
500    recipient_secret_key_hex: String,
501    message: EncryptedMessage,
502) -> Result<String, SoftchatError> {
503    let identity = LocalIdentity::from_secret_hex(&recipient_secret_key_hex)?;
504    let message = Nip44EncryptedMessage::try_from(message)?;
505    identity.decrypt_utf8(&message)
506}
507
508#[cfg(test)]
509mod tests {
510    use std::sync::Arc;
511    use std::thread;
512
513    use nostr::SecretKey;
514
515    use super::*;
516    use crate::{MAX_CIPHERTEXT_BYTES, MAX_NIP44_WRITER_PLAINTEXT_BYTES};
517
518    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
519    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
520    const CAROL_SECRET: &str = "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c";
521
522    #[test]
523    fn round_trips_unicode_in_both_directions() -> Result<(), SoftchatError> {
524        let alice_public = public_key(ALICE_SECRET.to_owned())?;
525        let bob_public = public_key(BOB_SECRET.to_owned())?;
526
527        let first = encrypt_message(
528            ALICE_SECRET.to_owned(),
529            bob_public,
530            "Hello Bob โ€” ่กจ ๐Ÿ•".to_owned(),
531        )?;
532        assert_eq!(first.algorithm, NIP44_V2_ALGORITHM);
533        assert_eq!(first.sender_public_key, alice_public);
534        assert_eq!(
535            decrypt_message(BOB_SECRET.to_owned(), first)?,
536            "Hello Bob โ€” ่กจ ๐Ÿ•"
537        );
538
539        let second = encrypt_message(
540            BOB_SECRET.to_owned(),
541            public_key(ALICE_SECRET.to_owned())?,
542            "Hello Alice ๐Ÿซƒ".to_owned(),
543        )?;
544        assert_eq!(
545            decrypt_message(ALICE_SECRET.to_owned(), second)?,
546            "Hello Alice ๐Ÿซƒ"
547        );
548
549        Ok(())
550    }
551
552    #[test]
553    fn decrypts_official_nip44_vector_through_facade() -> Result<(), SoftchatError> {
554        let recipient_secret = "0000000000000000000000000000000000000000000000000000000000000001";
555        let sender_secret = "0000000000000000000000000000000000000000000000000000000000000002";
556        let message = EncryptedMessage {
557            algorithm: NIP44_V2_ALGORITHM.to_owned(),
558            sender_public_key: public_key(sender_secret.to_owned())?,
559            payload: "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb".to_owned(),
560        };
561
562        assert_eq!(decrypt_message(recipient_secret.to_owned(), message)?, "a");
563        Ok(())
564    }
565
566    #[test]
567    fn validates_received_values() -> Result<(), SoftchatError> {
568        let bob_public = public_key(BOB_SECRET.to_owned())?;
569        assert_eq!(validate_public_key(bob_public.to_uppercase())?, bob_public);
570
571        let encrypted =
572            encrypt_message(ALICE_SECRET.to_owned(), bob_public, "persisted".to_owned())?;
573        assert_eq!(validate_encrypted_message(encrypted.clone())?, encrypted);
574        assert!(!format!("{encrypted:?}").contains(&encrypted.payload));
575        Ok(())
576    }
577
578    #[test]
579    fn opaque_handle_erases_idempotently() -> Result<(), SoftchatError> {
580        let secret_key =
581            SecretKey::from_hex(ALICE_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?;
582        let handle = LocalIdentityHandle::new(secret_key.to_secret_bytes().to_vec())?;
583
584        let expected_public_key = public_key(ALICE_SECRET.to_owned())?;
585        assert_eq!(handle.public_key()?, expected_public_key);
586        handle.erase();
587        handle.erase();
588        assert!(matches!(
589            handle.public_key(),
590            Err(SoftchatError::IdentityErased)
591        ));
592        Ok(())
593    }
594
595    #[test]
596    fn combined_import_returns_handle_and_cached_non_secret_values() -> Result<(), SoftchatError> {
597        let secret_key =
598            SecretKey::from_hex(ALICE_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?;
599        let imported = import_local_identity(secret_key.to_secret_bytes().to_vec())?;
600        assert_eq!(imported.public_key, public_key(ALICE_SECRET.to_owned())?);
601        #[cfg(all(feature = "android-jni", target_os = "android"))]
602        assert_ne!(imported.fast_handle_id, 0);
603        #[cfg(not(all(feature = "android-jni", target_os = "android")))]
604        assert_eq!(imported.fast_handle_id, 0);
605        assert_eq!(imported.handle.public_key()?, imported.public_key);
606        imported.handle.erase();
607        assert!(matches!(
608            imported.handle.public_key(),
609            Err(SoftchatError::IdentityErased)
610        ));
611        Ok(())
612    }
613
614    #[test]
615    fn opaque_handle_supports_concurrent_operations() -> Result<(), SoftchatError> {
616        let secret_key =
617            SecretKey::from_hex(ALICE_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?;
618        let handle = Arc::new(LocalIdentityHandle::new(
619            secret_key.to_secret_bytes().to_vec(),
620        )?);
621        let recipient = public_key(BOB_SECRET.to_owned())?;
622        let mut workers = Vec::new();
623
624        for index in 0..4 {
625            let handle = Arc::clone(&handle);
626            let recipient = recipient.clone();
627            workers.push(thread::spawn(move || {
628                handle.encrypt_message(recipient, format!("message {index}"))
629            }));
630        }
631
632        for worker in workers {
633            let message = worker
634                .join()
635                .map_err(|_| SoftchatError::EncryptionFailed)??;
636            assert_eq!(message.algorithm, NIP44_V2_ALGORITHM);
637        }
638        Ok(())
639    }
640
641    #[test]
642    fn rejects_invalid_keys() {
643        assert!(matches!(
644            public_key("not-a-secret".to_owned()),
645            Err(SoftchatError::InvalidSecretKey)
646        ));
647        assert!(matches!(
648            encrypt_message(ALICE_SECRET.to_owned(), "ff".repeat(32), "hello".to_owned()),
649            Err(SoftchatError::InvalidPublicKey)
650        ));
651    }
652
653    #[test]
654    fn rejects_empty_and_oversized_plaintext() -> Result<(), SoftchatError> {
655        let bob_public = public_key(BOB_SECRET.to_owned())?;
656
657        assert!(matches!(
658            encrypt_message(ALICE_SECRET.to_owned(), bob_public.clone(), String::new()),
659            Err(SoftchatError::EmptyPlaintext)
660        ));
661        assert!(matches!(
662            encrypt_message(
663                ALICE_SECRET.to_owned(),
664                bob_public,
665                "a".repeat(MAX_NIP44_WRITER_PLAINTEXT_BYTES + 1)
666            ),
667            Err(SoftchatError::PlaintextTooLarge)
668        ));
669
670        Ok(())
671    }
672
673    #[test]
674    fn rejects_wrong_recipient_and_tampering() -> Result<(), SoftchatError> {
675        let mut message = encrypt_message(
676            ALICE_SECRET.to_owned(),
677            public_key(BOB_SECRET.to_owned())?,
678            "authenticated".to_owned(),
679        )?;
680
681        assert!(matches!(
682            decrypt_message(CAROL_SECRET.to_owned(), message.clone()),
683            Err(SoftchatError::DecryptionFailed)
684        ));
685
686        let replacement = if message.payload.as_bytes().get(40) == Some(&b'A') {
687            'B'
688        } else {
689            'A'
690        };
691        message
692            .payload
693            .replace_range(40..41, &replacement.to_string());
694        assert!(matches!(
695            decrypt_message(BOB_SECRET.to_owned(), message),
696            Err(SoftchatError::DecryptionFailed)
697        ));
698
699        Ok(())
700    }
701
702    #[test]
703    fn rejects_unknown_algorithm_and_oversized_ciphertext() {
704        let unknown = EncryptedMessage {
705            algorithm: "future".to_owned(),
706            sender_public_key: "00".repeat(32),
707            payload: "payload".to_owned(),
708        };
709        assert!(matches!(
710            decrypt_message(BOB_SECRET.to_owned(), unknown),
711            Err(SoftchatError::UnsupportedAlgorithm)
712        ));
713
714        let oversized = EncryptedMessage {
715            algorithm: NIP44_V2_ALGORITHM.to_owned(),
716            sender_public_key: "00".repeat(32),
717            payload: "A".repeat(MAX_CIPHERTEXT_BYTES + 1),
718        };
719        assert!(matches!(
720            decrypt_message(BOB_SECRET.to_owned(), oversized),
721            Err(SoftchatError::CiphertextTooLarge)
722        ));
723    }
724}