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
10pub const NIP44_V2_ALGORITHM: &str = "nip44-v2";
12
13pub const MAX_NIP44_WRITER_PLAINTEXT_BYTES: usize = 65_535;
19
20pub const MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES: usize = 65_408;
26
27pub const MAX_NIP44_READER_PLAINTEXT_BYTES: usize = 327_680;
32
33pub const MAX_NIP44_ENCODED_PAYLOAD_BYTES: usize = 512 * 1024;
38
39pub const MAX_PLAINTEXT_BYTES: usize = MAX_NIP44_BINARY_WRITER_PLAINTEXT_BYTES;
41
42pub const MAX_CIPHERTEXT_BYTES: usize = MAX_NIP44_ENCODED_PAYLOAD_BYTES;
44
45#[derive(Clone, Eq, Hash, PartialEq)]
47pub struct NostrPublicKey {
48 inner: PublicKey,
49}
50
51impl NostrPublicKey {
52 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 #[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#[derive(Clone, Eq, Hash, PartialEq)]
108pub struct Nip44Payload {
109 encoded: String,
110}
111
112impl Nip44Payload {
113 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 #[must_use]
132 pub fn as_str(&self) -> &str {
133 &self.encoded
134 }
135
136 #[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#[derive(Clone, Debug, Eq, PartialEq)]
154pub struct Nip44EncryptedMessage {
155 sender_public_key: NostrPublicKey,
156 payload: Nip44Payload,
157}
158
159impl Nip44EncryptedMessage {
160 #[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 #[must_use]
171 pub const fn sender_public_key(&self) -> &NostrPublicKey {
172 &self.sender_public_key
173 }
174
175 #[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
192pub struct LocalIdentity {
198 keys: Keys,
199}
200
201impl LocalIdentity {
202 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 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 #[must_use]
248 pub fn public_key(&self) -> NostrPublicKey {
249 NostrPublicKey::from_inner(self.keys.public_key())
250 }
251
252 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 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 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 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 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}