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    pub(crate) fn into_parts(self) -> (NostrPublicKey, Nip44Payload) {
182        (self.sender_public_key, self.payload)
183    }
184}
185
186/// A locally held secret-key capability.
187///
188/// Secret bytes are parsed once and then owned by the upstream secret-bearing
189/// key type, whose destructor performs best-effort erasure. `Debug` never
190/// exposes secret material.
191pub struct LocalIdentity {
192    keys: Keys,
193}
194
195impl LocalIdentity {
196    /// Import a secret key from exactly 32 bytes.
197    ///
198    /// The caller remains responsible for clearing its own input buffer after
199    /// this synchronous import returns.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`SoftchatError::InvalidSecretKey`] for an invalid scalar.
204    pub fn from_secret_bytes(secret_key: &[u8]) -> Result<Self, SoftchatError> {
205        if secret_key.len() != SecretKey::LEN {
206            return Err(SoftchatError::InvalidSecretKey);
207        }
208
209        let secret_key =
210            SecretKey::from_slice(secret_key).map_err(|_| SoftchatError::InvalidSecretKey)?;
211        Ok(Self {
212            keys: Keys::new(secret_key),
213        })
214    }
215
216    /// Import a secret key from exactly 64 hexadecimal characters.
217    ///
218    /// This is retained for native Rust and legacy binding compatibility.
219    /// Byte import is preferred for scoped foreign-language identities.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`SoftchatError::InvalidSecretKey`] for an invalid scalar.
224    pub fn from_secret_hex(secret_key: &str) -> Result<Self, SoftchatError> {
225        if secret_key.len() != 64 || !secret_key.bytes().all(|byte| byte.is_ascii_hexdigit()) {
226            return Err(SoftchatError::InvalidSecretKey);
227        }
228
229        let secret_key =
230            SecretKey::from_hex(secret_key).map_err(|_| SoftchatError::InvalidSecretKey)?;
231        Ok(Self {
232            keys: Keys::new(secret_key),
233        })
234    }
235
236    /// Return this identity's public key.
237    #[must_use]
238    pub fn public_key(&self) -> NostrPublicKey {
239        NostrPublicKey::from_inner(self.keys.public_key())
240    }
241
242    /// Sign one explicit NIP-01 draft with this identity.
243    ///
244    /// The draft's timestamp, kind, tags, and content are preserved exactly.
245    /// No hidden clock read or tag normalization occurs.
246    ///
247    /// # Errors
248    ///
249    /// Returns a stable draft-validation, size, or signing failure.
250    pub fn sign_event(&self, draft: NostrEventDraft) -> Result<SignedNostrEvent, SoftchatError> {
251        let input_bytes = draft.content().len();
252        let result = draft.sign_with_keys(&self.keys);
253        if result.is_ok() {
254            diagnostics::record_success(Operation::SignEvent, input_bytes, 64);
255        } else {
256            diagnostics::record_rejection(input_bytes);
257        }
258        result
259    }
260
261    /// Encrypt arbitrary non-empty bytes with NIP-44 v2.
262    ///
263    /// # Errors
264    ///
265    /// Returns a stable validation or redacted encryption failure.
266    pub fn encrypt(
267        &self,
268        recipient: &NostrPublicKey,
269        plaintext: &[u8],
270    ) -> Result<Nip44EncryptedMessage, SoftchatError> {
271        validate_binary_plaintext(plaintext)?;
272
273        let encoded =
274            nip44_profile::encrypt_binary(self.keys.secret_key(), recipient.as_inner(), plaintext)
275                .map_err(|_| SoftchatError::EncryptionFailed)?;
276
277        let message =
278            Nip44EncryptedMessage::new(self.public_key(), Nip44Payload::from_encoded(encoded)?);
279        diagnostics::record_success(
280            Operation::Nip44Encrypt,
281            plaintext.len(),
282            message.payload().as_str().len(),
283        );
284        Ok(message)
285    }
286
287    /// Encrypt one non-empty UTF-8 string with NIP-44 v2.
288    ///
289    /// # Errors
290    ///
291    /// Returns a stable validation or redacted encryption failure.
292    pub fn encrypt_utf8(
293        &self,
294        recipient: &NostrPublicKey,
295        plaintext: &str,
296    ) -> Result<Nip44EncryptedMessage, SoftchatError> {
297        validate_plaintext(plaintext.as_bytes())?;
298
299        let encoded =
300            nip44_profile::encrypt_utf8(self.keys.secret_key(), recipient.as_inner(), plaintext)
301                .map_err(|_| SoftchatError::EncryptionFailed)?;
302
303        let message =
304            Nip44EncryptedMessage::new(self.public_key(), Nip44Payload::from_encoded(encoded)?);
305        diagnostics::record_success(
306            Operation::Nip44Encrypt,
307            plaintext.len(),
308            message.payload().as_str().len(),
309        );
310        Ok(message)
311    }
312
313    /// Authenticate and decrypt a NIP-44 message to bytes.
314    ///
315    /// # Errors
316    ///
317    /// All authentication, encoding, padding, and version failures map to
318    /// [`SoftchatError::DecryptionFailed`].
319    pub fn decrypt(&self, message: &Nip44EncryptedMessage) -> Result<Vec<u8>, SoftchatError> {
320        let input_bytes = message.payload().as_str().len();
321        let result = nip44_profile::decrypt(
322            self.keys.secret_key(),
323            message.sender_public_key().as_inner(),
324            message.payload().as_str(),
325        )
326        .map_err(|_| SoftchatError::DecryptionFailed);
327        match &result {
328            Ok(plaintext) => {
329                diagnostics::record_success(Operation::Nip44Decrypt, input_bytes, plaintext.len());
330            }
331            Err(_) => diagnostics::record_rejection(input_bytes),
332        }
333        result
334    }
335
336    /// Authenticate and decrypt a NIP-44 message as UTF-8.
337    ///
338    /// # Errors
339    ///
340    /// All authentication, encoding, padding, version, and UTF-8 failures map
341    /// to [`SoftchatError::DecryptionFailed`].
342    pub fn decrypt_utf8(&self, message: &Nip44EncryptedMessage) -> Result<String, SoftchatError> {
343        String::from_utf8(self.decrypt(message)?).map_err(|_| SoftchatError::DecryptionFailed)
344    }
345
346    pub(crate) const fn keys(&self) -> &Keys {
347        &self.keys
348    }
349}
350
351impl fmt::Debug for LocalIdentity {
352    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
353        formatter
354            .debug_struct("LocalIdentity")
355            .field("public_key", &self.public_key())
356            .field("secret_key", &"[redacted]")
357            .finish()
358    }
359}
360
361fn validate_plaintext(plaintext: &[u8]) -> Result<(), SoftchatError> {
362    if plaintext.is_empty() {
363        return Err(SoftchatError::EmptyPlaintext);
364    }
365    if plaintext.len() > MAX_NIP44_WRITER_PLAINTEXT_BYTES {
366        return Err(SoftchatError::PlaintextTooLarge);
367    }
368    Ok(())
369}
370
371fn validate_binary_plaintext(plaintext: &[u8]) -> Result<(), SoftchatError> {
372    if plaintext.is_empty() {
373        return Err(SoftchatError::EmptyPlaintext);
374    }
375    if plaintext.len() > MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES {
376        return Err(SoftchatError::PlaintextTooLarge);
377    }
378    Ok(())
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
386    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
387    const CAROL_SECRET: &str = "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c";
388
389    #[test]
390    fn typed_identities_round_trip_utf8_and_bytes() -> Result<(), SoftchatError> {
391        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
392        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
393
394        let text = alice.encrypt_utf8(&bob.public_key(), "Hello โ€” ่กจ ๐Ÿ”")?;
395        assert_eq!(bob.decrypt_utf8(&text)?, "Hello โ€” ่กจ ๐Ÿ”");
396
397        let bytes = alice.encrypt(&bob.public_key(), &[0, 1, 127, 128, 255])?;
398        assert_eq!(bob.decrypt(&bytes)?, [0, 1, 127, 128, 255]);
399        Ok(())
400    }
401
402    #[test]
403    fn typed_values_reject_invalid_inputs() {
404        assert!(matches!(
405            NostrPublicKey::from_hex("ff"),
406            Err(SoftchatError::InvalidPublicKey)
407        ));
408        assert!(matches!(
409            Nip44Payload::from_encoded("A".repeat(MAX_CIPHERTEXT_BYTES + 1)),
410            Err(SoftchatError::CiphertextTooLarge)
411        ));
412        assert!(matches!(
413            LocalIdentity::from_secret_bytes(&[1; 31]),
414            Err(SoftchatError::InvalidSecretKey)
415        ));
416    }
417
418    #[test]
419    fn binary_writer_uses_its_explicit_legacy_bound() -> Result<(), SoftchatError> {
420        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
421        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
422
423        let maximum = vec![b'b'; MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES];
424        let message = alice.encrypt(&bob.public_key(), &maximum)?;
425        assert_eq!(bob.decrypt(&message)?, maximum);
426        assert!(matches!(
427            alice.encrypt(
428                &bob.public_key(),
429                &vec![b'b'; MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES + 1],
430            ),
431            Err(SoftchatError::PlaintextTooLarge)
432        ));
433        Ok(())
434    }
435
436    #[test]
437    fn typed_identity_reports_redacted_failure_for_wrong_recipient() -> Result<(), SoftchatError> {
438        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
439        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
440        let carol = LocalIdentity::from_secret_hex(CAROL_SECRET)?;
441        let message = alice.encrypt_utf8(&bob.public_key(), "authenticated")?;
442
443        assert!(matches!(
444            carol.decrypt_utf8(&message),
445            Err(SoftchatError::DecryptionFailed)
446        ));
447        Ok(())
448    }
449
450    #[test]
451    fn identity_debug_is_redacted() -> Result<(), SoftchatError> {
452        let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
453        let debug = format!("{identity:?}");
454
455        assert!(debug.contains("[redacted]"));
456        assert!(!debug.contains(ALICE_SECRET));
457        Ok(())
458    }
459}