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#[derive(Clone, Eq, PartialEq)]
22#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
23pub struct EncryptedMessage {
24 pub algorithm: String,
26 pub sender_public_key: String,
28 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#[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#[derive(Clone, Debug)]
82#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
83pub struct ImportedLocalIdentity {
84 pub handle: Arc<LocalIdentityHandle>,
86 pub public_key: String,
88 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 #[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 pub fn public_key(&self) -> Result<String, SoftchatError> {
140 self.with_identity(|_| Ok(self.public_key.clone()))
141 }
142
143 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 #[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 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 #[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 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 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 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 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 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 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#[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#[cfg_attr(feature = "native-bindings", uniffi::export)]
431#[must_use]
432pub fn version() -> String {
433 env!("CARGO_PKG_VERSION").to_owned()
434}
435
436#[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#[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#[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#[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#[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}