Skip to main content

softchat/
core.rs

1use std::fmt;
2use std::str::FromStr;
3
4use nostr::{Keys, PublicKey, SecretKey};
5
6use crate::diagnostics::{self, Operation};
7use crate::nip44_profile;
8use crate::{NostrEventDraft, SignedNostrEvent, SoftchatError};
9
10/// Algorithm identifier carried by encrypted-message binding values.
11pub const NIP44_V2_ALGORITHM: &str = "nip44-v2";
12
13/// Maximum plaintext size emitted by the Softchat NIP-44 v2 writer.
14///
15/// Released Android and iOS clients use the legacy two-byte length prefix, so
16/// writers remain at its complete `u16` range until both clients accept the
17/// extended format.
18pub const MAX_NIP44_WRITER_PLAINTEXT_BYTES: usize = 65_535;
19
20/// Maximum arbitrary-byte plaintext emitted through the native Rust API.
21///
22/// The pinned `nostr` codec owns this legacy compatibility path and applies
23/// its conservative pre-extension bound. NIP-44 text writers use
24/// [`MAX_NIP44_WRITER_PLAINTEXT_BYTES`].
25pub const MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES: usize = 65_408;
26
27/// Maximum plaintext size accepted by the Softchat NIP-44 v2 reader.
28///
29/// This bounded compatibility profile accepts the current six-byte extended
30/// prefix without adopting the NIP's theoretical multi-gigabyte maximum.
31pub const MAX_NIP44_READER_PLAINTEXT_BYTES: usize = 327_680;
32
33/// Cheap encoded-payload bound applied before base64 decoding.
34///
35/// The 512-KiB bound matches the current complete relay-message bound and
36/// limits temporary decoding and decryption allocations.
37pub const MAX_NIP44_ENCODED_PAYLOAD_BYTES: usize = 512 * 1024;
38
39/// Compatibility name for the native arbitrary-byte writer limit.
40pub const MAX_PLAINTEXT_BYTES: usize = MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES;
41
42/// Compatibility name for the current encoded-payload reader limit.
43pub const MAX_CIPHERTEXT_BYTES: usize = MAX_NIP44_ENCODED_PAYLOAD_BYTES;
44
45/// A validated lowercase hexadecimal Nostr public key.
46#[derive(Clone, Eq, Hash, PartialEq)]
47pub struct NostrPublicKey {
48    inner: PublicKey,
49}
50
51impl NostrPublicKey {
52    /// Parse one 32-byte hexadecimal x-only public key.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`SoftchatError::InvalidPublicKey`] when `value` is not a
57    /// valid public key.
58    pub fn from_hex(value: &str) -> Result<Self, SoftchatError> {
59        if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
60            return Err(SoftchatError::InvalidPublicKey);
61        }
62
63        let inner = PublicKey::from_hex(value).map_err(|_| SoftchatError::InvalidPublicKey)?;
64        inner.xonly().map_err(|_| SoftchatError::InvalidPublicKey)?;
65        Ok(Self { inner })
66    }
67
68    /// Return the canonical lowercase hexadecimal representation.
69    #[must_use]
70    pub fn to_hex(&self) -> String {
71        self.inner.to_hex()
72    }
73
74    pub(crate) fn from_inner(inner: PublicKey) -> Self {
75        Self { inner }
76    }
77
78    pub(crate) const fn as_inner(&self) -> &PublicKey {
79        &self.inner
80    }
81}
82
83impl fmt::Debug for NostrPublicKey {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        formatter
86            .debug_tuple("NostrPublicKey")
87            .field(&self.to_hex())
88            .finish()
89    }
90}
91
92impl fmt::Display for NostrPublicKey {
93    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94        formatter.write_str(&self.to_hex())
95    }
96}
97
98impl FromStr for NostrPublicKey {
99    type Err = SoftchatError;
100
101    fn from_str(value: &str) -> Result<Self, Self::Err> {
102        Self::from_hex(value)
103    }
104}
105
106/// One bounded, encoded NIP-44 v2 payload.
107#[derive(Clone, Eq, Hash, PartialEq)]
108pub struct Nip44Payload {
109    encoded: String,
110}
111
112impl Nip44Payload {
113    /// Retain an encoded payload after applying the cheap resource bound.
114    ///
115    /// Authentication, base64, padding, and version validation intentionally
116    /// remain part of decryption so they map to one redacted failure.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`SoftchatError::CiphertextTooLarge`] when the encoded value
121    /// exceeds the current Softchat bound.
122    pub fn from_encoded(encoded: impl Into<String>) -> Result<Self, SoftchatError> {
123        let encoded = encoded.into();
124        if encoded.len() > MAX_NIP44_ENCODED_PAYLOAD_BYTES {
125            return Err(SoftchatError::CiphertextTooLarge);
126        }
127        Ok(Self { encoded })
128    }
129
130    /// Borrow the standard-base64 encoded payload.
131    #[must_use]
132    pub fn as_str(&self) -> &str {
133        &self.encoded
134    }
135
136    /// Consume the value and return its encoded representation.
137    #[must_use]
138    pub fn into_string(self) -> String {
139        self.encoded
140    }
141}
142
143impl fmt::Debug for Nip44Payload {
144    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
145        formatter
146            .debug_struct("Nip44Payload")
147            .field("encoded_bytes", &self.encoded.len())
148            .finish()
149    }
150}
151
152/// A typed native-Rust NIP-44 v2 message.
153#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct Nip44EncryptedMessage {
155    sender_public_key: NostrPublicKey,
156    payload: Nip44Payload,
157}
158
159impl Nip44EncryptedMessage {
160    /// Construct a typed message from validated values.
161    #[must_use]
162    pub const fn new(sender_public_key: NostrPublicKey, payload: Nip44Payload) -> Self {
163        Self {
164            sender_public_key,
165            payload,
166        }
167    }
168
169    /// Return the authenticated sender candidate used by NIP-44.
170    #[must_use]
171    pub const fn sender_public_key(&self) -> &NostrPublicKey {
172        &self.sender_public_key
173    }
174
175    /// Return the encoded NIP-44 payload.
176    #[must_use]
177    pub const fn payload(&self) -> &Nip44Payload {
178        &self.payload
179    }
180
181    #[cfg(any(
182        feature = "sqlite-storage",
183        feature = "native-bindings",
184        all(feature = "javascript-bindings", target_arch = "wasm32"),
185        test
186    ))]
187    pub(crate) fn into_parts(self) -> (NostrPublicKey, Nip44Payload) {
188        (self.sender_public_key, self.payload)
189    }
190}
191
192/// A locally held secret-key capability.
193///
194/// Secret bytes are parsed once and then owned by the upstream secret-bearing
195/// key type, whose destructor performs best-effort erasure. `Debug` never
196/// exposes secret material.
197pub struct LocalIdentity {
198    keys: Keys,
199}
200
201impl LocalIdentity {
202    /// Import a secret key from exactly 32 bytes.
203    ///
204    /// The caller remains responsible for clearing its own input buffer after
205    /// this synchronous import returns.
206    ///
207    /// [Guide and performance](https://softchat-sdk.jackl.dev/identity/#local-identity-lifecycle)
208    ///
209    /// # Errors
210    ///
211    /// Returns [`SoftchatError::InvalidSecretKey`] for an invalid scalar.
212    pub fn from_secret_bytes(secret_key: &[u8]) -> Result<Self, SoftchatError> {
213        if secret_key.len() != SecretKey::LEN {
214            return Err(SoftchatError::InvalidSecretKey);
215        }
216
217        let secret_key =
218            SecretKey::from_slice(secret_key).map_err(|_| SoftchatError::InvalidSecretKey)?;
219        Ok(Self {
220            keys: Keys::new(secret_key),
221        })
222    }
223
224    /// Import a secret key from exactly 64 hexadecimal characters.
225    ///
226    /// This is retained for native Rust and legacy binding compatibility.
227    /// Byte import is preferred for scoped foreign-language identities.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`SoftchatError::InvalidSecretKey`] for an invalid scalar.
232    pub fn from_secret_hex(secret_key: &str) -> Result<Self, SoftchatError> {
233        if secret_key.len() != 64 || !secret_key.bytes().all(|byte| byte.is_ascii_hexdigit()) {
234            return Err(SoftchatError::InvalidSecretKey);
235        }
236
237        let secret_key =
238            SecretKey::from_hex(secret_key).map_err(|_| SoftchatError::InvalidSecretKey)?;
239        Ok(Self {
240            keys: Keys::new(secret_key),
241        })
242    }
243
244    /// Return this identity's public key.
245    ///
246    /// [Guide and performance](https://softchat-sdk.jackl.dev/identity/#local-identity-lifecycle)
247    #[must_use]
248    pub fn public_key(&self) -> NostrPublicKey {
249        NostrPublicKey::from_inner(self.keys.public_key())
250    }
251
252    /// Sign one explicit NIP-01 draft with this identity.
253    ///
254    /// The draft's timestamp, kind, tags, and content are preserved exactly.
255    /// No hidden clock read or tag normalization occurs.
256    ///
257    /// [Guide and performance](https://softchat-sdk.jackl.dev/nostr-and-crypto/#author-a-nip-01-event)
258    ///
259    /// # Errors
260    ///
261    /// Returns a stable draft-validation, size, or signing failure.
262    pub fn sign_event(&self, draft: NostrEventDraft) -> Result<SignedNostrEvent, SoftchatError> {
263        let input_bytes = draft.content().len();
264        let result = draft.sign_with_keys(&self.keys);
265        if result.is_ok() {
266            diagnostics::record_success(Operation::SignEvent, input_bytes, 64);
267        } else {
268            diagnostics::record_rejection(input_bytes);
269        }
270        result
271    }
272
273    /// Encrypt arbitrary non-empty bytes with NIP-44 v2.
274    ///
275    /// # Errors
276    ///
277    /// Returns a stable validation or redacted encryption failure.
278    pub fn encrypt(
279        &self,
280        recipient: &NostrPublicKey,
281        plaintext: &[u8],
282    ) -> Result<Nip44EncryptedMessage, SoftchatError> {
283        validate_binary_plaintext(plaintext)?;
284
285        let encoded =
286            nip44_profile::encrypt_binary(self.keys.secret_key(), recipient.as_inner(), plaintext)
287                .map_err(|_| SoftchatError::EncryptionFailed)?;
288
289        let message =
290            Nip44EncryptedMessage::new(self.public_key(), Nip44Payload::from_encoded(encoded)?);
291        diagnostics::record_success(
292            Operation::Nip44Encrypt,
293            plaintext.len(),
294            message.payload().as_str().len(),
295        );
296        Ok(message)
297    }
298
299    /// Encrypt one non-empty UTF-8 string with NIP-44 v2.
300    ///
301    /// [Guide and performance](https://softchat-sdk.jackl.dev/nostr-and-crypto/#nip-44-v2)
302    ///
303    /// # Errors
304    ///
305    /// Returns a stable validation or redacted encryption failure.
306    pub fn encrypt_utf8(
307        &self,
308        recipient: &NostrPublicKey,
309        plaintext: &str,
310    ) -> Result<Nip44EncryptedMessage, SoftchatError> {
311        validate_plaintext(plaintext.as_bytes())?;
312
313        let encoded =
314            nip44_profile::encrypt_utf8(self.keys.secret_key(), recipient.as_inner(), plaintext)
315                .map_err(|_| SoftchatError::EncryptionFailed)?;
316
317        let message =
318            Nip44EncryptedMessage::new(self.public_key(), Nip44Payload::from_encoded(encoded)?);
319        diagnostics::record_success(
320            Operation::Nip44Encrypt,
321            plaintext.len(),
322            message.payload().as_str().len(),
323        );
324        Ok(message)
325    }
326
327    /// Authenticate and decrypt a NIP-44 message to bytes.
328    ///
329    /// # Errors
330    ///
331    /// All authentication, encoding, padding, and version failures map to
332    /// [`SoftchatError::DecryptionFailed`].
333    pub fn decrypt(&self, message: &Nip44EncryptedMessage) -> Result<Vec<u8>, SoftchatError> {
334        let input_bytes = message.payload().as_str().len();
335        let result = nip44_profile::decrypt(
336            self.keys.secret_key(),
337            message.sender_public_key().as_inner(),
338            message.payload().as_str(),
339        )
340        .map_err(|_| SoftchatError::DecryptionFailed);
341        match &result {
342            Ok(plaintext) => {
343                diagnostics::record_success(Operation::Nip44Decrypt, input_bytes, plaintext.len());
344            }
345            Err(_) => diagnostics::record_rejection(input_bytes),
346        }
347        result
348    }
349
350    /// Authenticate and decrypt a NIP-44 message as UTF-8.
351    ///
352    /// [Guide and performance](https://softchat-sdk.jackl.dev/nostr-and-crypto/#nip-44-v2)
353    ///
354    /// # Errors
355    ///
356    /// All authentication, encoding, padding, version, and UTF-8 failures map
357    /// to [`SoftchatError::DecryptionFailed`].
358    pub fn decrypt_utf8(&self, message: &Nip44EncryptedMessage) -> Result<String, SoftchatError> {
359        String::from_utf8(self.decrypt(message)?).map_err(|_| SoftchatError::DecryptionFailed)
360    }
361
362    pub(crate) const fn keys(&self) -> &Keys {
363        &self.keys
364    }
365}
366
367impl fmt::Debug for LocalIdentity {
368    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369        formatter
370            .debug_struct("LocalIdentity")
371            .field("public_key", &self.public_key())
372            .field("secret_key", &"[redacted]")
373            .finish()
374    }
375}
376
377fn validate_plaintext(plaintext: &[u8]) -> Result<(), SoftchatError> {
378    if plaintext.is_empty() {
379        return Err(SoftchatError::EmptyPlaintext);
380    }
381    if plaintext.len() > MAX_NIP44_WRITER_PLAINTEXT_BYTES {
382        return Err(SoftchatError::PlaintextTooLarge);
383    }
384    Ok(())
385}
386
387fn validate_binary_plaintext(plaintext: &[u8]) -> Result<(), SoftchatError> {
388    if plaintext.is_empty() {
389        return Err(SoftchatError::EmptyPlaintext);
390    }
391    if plaintext.len() > MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES {
392        return Err(SoftchatError::PlaintextTooLarge);
393    }
394    Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
402    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
403    const CAROL_SECRET: &str = "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c";
404
405    #[test]
406    fn typed_identities_round_trip_utf8_and_bytes() -> Result<(), SoftchatError> {
407        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
408        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
409
410        let text = alice.encrypt_utf8(&bob.public_key(), "Hello โ€” ่กจ ๐Ÿ”")?;
411        assert_eq!(bob.decrypt_utf8(&text)?, "Hello โ€” ่กจ ๐Ÿ”");
412
413        let bytes = alice.encrypt(&bob.public_key(), &[0, 1, 127, 128, 255])?;
414        assert_eq!(bob.decrypt(&bytes)?, [0, 1, 127, 128, 255]);
415        Ok(())
416    }
417
418    #[test]
419    fn typed_values_reject_invalid_inputs() {
420        assert!(matches!(
421            NostrPublicKey::from_hex("ff"),
422            Err(SoftchatError::InvalidPublicKey)
423        ));
424        assert!(matches!(
425            Nip44Payload::from_encoded("A".repeat(MAX_CIPHERTEXT_BYTES + 1)),
426            Err(SoftchatError::CiphertextTooLarge)
427        ));
428        assert!(matches!(
429            LocalIdentity::from_secret_bytes(&[1; 31]),
430            Err(SoftchatError::InvalidSecretKey)
431        ));
432    }
433
434    #[test]
435    fn binary_writer_uses_its_explicit_legacy_bound() -> Result<(), SoftchatError> {
436        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
437        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
438
439        let maximum = vec![b'b'; MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES];
440        let message = alice.encrypt(&bob.public_key(), &maximum)?;
441        assert_eq!(bob.decrypt(&message)?, maximum);
442        assert!(matches!(
443            alice.encrypt(
444                &bob.public_key(),
445                &vec![b'b'; MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES + 1],
446            ),
447            Err(SoftchatError::PlaintextTooLarge)
448        ));
449        Ok(())
450    }
451
452    #[test]
453    fn typed_identity_reports_redacted_failure_for_wrong_recipient() -> Result<(), SoftchatError> {
454        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
455        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
456        let carol = LocalIdentity::from_secret_hex(CAROL_SECRET)?;
457        let message = alice.encrypt_utf8(&bob.public_key(), "authenticated")?;
458
459        assert!(matches!(
460            carol.decrypt_utf8(&message),
461            Err(SoftchatError::DecryptionFailed)
462        ));
463        Ok(())
464    }
465
466    #[test]
467    fn identity_debug_is_redacted() -> Result<(), SoftchatError> {
468        let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
469        let debug = format!("{identity:?}");
470
471        assert!(debug.contains("[redacted]"));
472        assert!(!debug.contains(ALICE_SECRET));
473        Ok(())
474    }
475}