1use nostr::Keys;
4use nostr::secp256k1::rand::Rng;
5use nostr::secp256k1::rand::rngs::OsRng;
6
7use crate::diagnostics::{self, Operation};
8use crate::nip44_profile;
9use crate::{
10 LocalIdentity, MAX_PORTABLE_TIMESTAMP_SECONDS, Nip44EncryptedMessage, Nip44Payload,
11 NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrRumor, NostrTag,
12 SignedNostrEvent, SoftchatError,
13};
14
15pub const MAX_NIP59_TIMESTAMP_TWEAK_SECONDS: u64 = 172_800;
17
18#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
20#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
21pub enum Nip59EnvelopeKind {
22 Durable,
24 Ephemeral,
26}
27
28impl Nip59EnvelopeKind {
29 const fn event_kind(self) -> NostrEventKind {
30 match self {
31 Self::Durable => NostrEventKind::GIFT_WRAP,
32 Self::Ephemeral => NostrEventKind::EPHEMERAL_GIFT_WRAP,
33 }
34 }
35
36 fn from_event_kind(kind: NostrEventKind) -> Result<Self, SoftchatError> {
37 if kind == NostrEventKind::GIFT_WRAP {
38 Ok(Self::Durable)
39 } else if kind == NostrEventKind::EPHEMERAL_GIFT_WRAP {
40 Ok(Self::Ephemeral)
41 } else {
42 Err(SoftchatError::InvalidNip59Envelope)
43 }
44 }
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct UnwrappedNip59Envelope {
50 outer_event_id: NostrEventId,
51 seal_event_id: NostrEventId,
52 kind: Nip59EnvelopeKind,
53 rumor: NostrRumor,
54}
55
56impl UnwrappedNip59Envelope {
57 #[must_use]
59 pub const fn outer_event_id(&self) -> NostrEventId {
60 self.outer_event_id
61 }
62
63 #[must_use]
65 pub const fn seal_event_id(&self) -> NostrEventId {
66 self.seal_event_id
67 }
68
69 #[must_use]
71 pub const fn kind(&self) -> Nip59EnvelopeKind {
72 self.kind
73 }
74
75 #[must_use]
77 pub const fn rumor(&self) -> &NostrRumor {
78 &self.rumor
79 }
80
81 #[must_use]
83 pub fn into_rumor(self) -> NostrRumor {
84 self.rumor
85 }
86}
87
88#[derive(Clone, Debug, Eq, PartialEq)]
90#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
91pub struct UnwrappedEnvelope {
92 pub outer_event_id: String,
94 pub seal_event_id: String,
96 pub kind: Nip59EnvelopeKind,
98 pub rumor: crate::RumorEvent,
100}
101
102impl From<UnwrappedNip59Envelope> for UnwrappedEnvelope {
103 fn from(envelope: UnwrappedNip59Envelope) -> Self {
104 Self {
105 outer_event_id: envelope.outer_event_id.to_hex(),
106 seal_event_id: envelope.seal_event_id.to_hex(),
107 kind: envelope.kind,
108 rumor: crate::RumorEvent::from(&envelope.rumor),
109 }
110 }
111}
112
113impl LocalIdentity {
114 pub fn create_rumor(&self, draft: NostrEventDraft) -> Result<NostrRumor, SoftchatError> {
122 NostrRumor::new(self.public_key(), draft)
123 }
124
125 pub fn gift_wrap(
135 &self,
136 recipient: &NostrPublicKey,
137 rumor: &NostrRumor,
138 kind: Nip59EnvelopeKind,
139 now: u64,
140 ) -> Result<SignedNostrEvent, SoftchatError> {
141 if now > MAX_PORTABLE_TIMESTAMP_SECONDS || rumor.public_key() != self.public_key() {
142 diagnostics::record_rejection(rumor.content().len());
143 return Err(SoftchatError::Nip59EnvelopeCreationFailed);
144 }
145
146 let mut rng = OsRng;
147 let largest_offset = now.min(MAX_NIP59_TIMESTAMP_TWEAK_SECONDS);
148 let seal_offset = rng.gen_range(0..=largest_offset);
149 let wrapper_offset = rng.gen_range(0..=largest_offset);
150 let wrapper_keys = Keys::generate_with_rng(&mut rng);
151
152 let result = self
153 .gift_wrap_with_parameters(
154 recipient,
155 rumor,
156 kind,
157 now - seal_offset,
158 now - wrapper_offset,
159 &wrapper_keys,
160 )
161 .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed);
162 match &result {
163 Ok(event) => diagnostics::record_success(
164 Operation::WrapEnvelope,
165 rumor.content().len(),
166 event.content().len(),
167 ),
168 Err(_) => diagnostics::record_rejection(rumor.content().len()),
169 }
170 result
171 }
172
173 pub fn unwrap_gift_wrap(
182 &self,
183 outer: &SignedNostrEvent,
184 ) -> Result<UnwrappedNip59Envelope, SoftchatError> {
185 let result = self
186 .unwrap_gift_wrap_inner(outer)
187 .map_err(|_| SoftchatError::InvalidNip59Envelope);
188 match &result {
189 Ok(envelope) => diagnostics::record_success(
190 Operation::UnwrapEnvelope,
191 outer.content().len(),
192 envelope.rumor().content().len(),
193 ),
194 Err(_) => diagnostics::record_rejection(outer.content().len()),
195 }
196 result
197 }
198
199 fn gift_wrap_with_parameters(
200 &self,
201 recipient: &NostrPublicKey,
202 rumor: &NostrRumor,
203 kind: Nip59EnvelopeKind,
204 seal_created_at: u64,
205 wrapper_created_at: u64,
206 wrapper_keys: &Keys,
207 ) -> Result<SignedNostrEvent, SoftchatError> {
208 if rumor.public_key() != self.public_key() {
209 return Err(SoftchatError::Nip59EnvelopeCreationFailed);
210 }
211
212 let encrypted_rumor = nip44_profile::encrypt_utf8(
213 self.keys().secret_key(),
214 recipient.as_inner(),
215 &rumor.to_json()?,
216 )
217 .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
218 let seal = NostrEventDraft::new(
219 seal_created_at,
220 NostrEventKind::SEAL,
221 Vec::new(),
222 encrypted_rumor,
223 )?
224 .sign_with_keys(self.keys())?;
225
226 let encrypted_seal = nip44_profile::encrypt_utf8(
227 wrapper_keys.secret_key(),
228 recipient.as_inner(),
229 &seal.to_json()?,
230 )
231 .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
232 let recipient_tag = NostrTag::new(vec!["p".to_owned(), recipient.to_hex()])?;
233 NostrEventDraft::new(
234 wrapper_created_at,
235 kind.event_kind(),
236 vec![recipient_tag],
237 encrypted_seal,
238 )?
239 .sign_with_keys(wrapper_keys)
240 }
241
242 fn unwrap_gift_wrap_inner(
243 &self,
244 outer: &SignedNostrEvent,
245 ) -> Result<UnwrappedNip59Envelope, SoftchatError> {
246 let kind = Nip59EnvelopeKind::from_event_kind(outer.kind())?;
247 validate_recipient_route(outer.tags(), &self.public_key())?;
248
249 let encrypted_seal = Nip44EncryptedMessage::new(
250 outer.public_key(),
251 Nip44Payload::from_encoded(outer.content())?,
252 );
253 let seal_json = self.decrypt_utf8(&encrypted_seal)?;
254 let seal = SignedNostrEvent::from_json(&seal_json)?;
255 if seal.kind() != NostrEventKind::SEAL || seal.tags().len() != 0 {
256 return Err(SoftchatError::InvalidNip59Envelope);
257 }
258
259 let encrypted_rumor = Nip44EncryptedMessage::new(
260 seal.public_key(),
261 Nip44Payload::from_encoded(seal.content())?,
262 );
263 let rumor_json = self.decrypt_utf8(&encrypted_rumor)?;
264 let rumor = NostrRumor::from_json(&rumor_json)?;
265 if rumor.public_key() != seal.public_key() {
266 return Err(SoftchatError::InvalidNip59Envelope);
267 }
268
269 Ok(UnwrappedNip59Envelope {
270 outer_event_id: outer.id(),
271 seal_event_id: seal.id(),
272 kind,
273 rumor,
274 })
275 }
276}
277
278fn validate_recipient_route<'a>(
279 tags: impl Iterator<Item = &'a [String]>,
280 recipient: &NostrPublicKey,
281) -> Result<(), SoftchatError> {
282 let mut matching_recipient = false;
283 let mut recipient_tags = 0_u8;
284
285 for tag in tags {
286 if tag.first().map(String::as_str) != Some("p") {
287 continue;
288 }
289 recipient_tags = recipient_tags
290 .checked_add(1)
291 .ok_or(SoftchatError::InvalidNip59Envelope)?;
292 let raw_public_key = tag.get(1).ok_or(SoftchatError::InvalidNip59Envelope)?;
293 let public_key = NostrPublicKey::from_hex(raw_public_key)?;
294 matching_recipient = public_key.to_hex() == *raw_public_key && &public_key == recipient;
295 }
296
297 if recipient_tags == 1 && matching_recipient {
298 Ok(())
299 } else {
300 Err(SoftchatError::InvalidNip59Envelope)
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use nostr::SecretKey;
307 use serde::Deserialize;
308
309 use super::*;
310
311 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
312 const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
313 const WRAPPER_SECRET: &str = "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c";
314 const OFFICIAL_EXAMPLE: &str =
315 include_str!("../../../fixtures/nostr/nip59/nip59-official-example.json");
316
317 #[derive(Deserialize)]
318 #[serde(rename_all = "camelCase")]
319 struct OfficialExample {
320 recipient_secret: String,
321 rumor: serde_json::Value,
322 seal: serde_json::Value,
323 gift_wrap: serde_json::Value,
324 }
325
326 struct OuterParameters {
327 kind: NostrEventKind,
328 tags: Vec<NostrTag>,
329 tamper_ciphertext: bool,
330 }
331
332 fn direct_message_rumor(
333 alice: &LocalIdentity,
334 bob: &LocalIdentity,
335 ) -> Result<NostrRumor, SoftchatError> {
336 alice.create_rumor(NostrEventDraft::new(
337 1_700_000_000,
338 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
339 vec![NostrTag::new(vec![
340 "p".to_owned(),
341 bob.public_key().to_hex(),
342 ])?],
343 "hello Bob",
344 )?)
345 }
346
347 fn custom_envelope(
348 seal_signer: &LocalIdentity,
349 recipient: &LocalIdentity,
350 wrapper_keys: &Keys,
351 rumor_json: &str,
352 seal_tags: Vec<NostrTag>,
353 outer: OuterParameters,
354 ) -> Result<SignedNostrEvent, SoftchatError> {
355 let encrypted_rumor = nip44_profile::encrypt_utf8(
356 seal_signer.keys().secret_key(),
357 recipient.public_key().as_inner(),
358 rumor_json,
359 )
360 .map_err(|_| SoftchatError::EncryptionFailed)?;
361 let seal = NostrEventDraft::new(
362 1_700_000_100,
363 NostrEventKind::SEAL,
364 seal_tags,
365 encrypted_rumor,
366 )?
367 .sign_with_keys(seal_signer.keys())?;
368 let mut encrypted_seal = nip44_profile::encrypt_utf8(
369 wrapper_keys.secret_key(),
370 recipient.public_key().as_inner(),
371 &seal.to_json()?,
372 )
373 .map_err(|_| SoftchatError::EncryptionFailed)?;
374 if outer.tamper_ciphertext {
375 let replacement = if encrypted_seal.ends_with('A') {
376 "B"
377 } else {
378 "A"
379 };
380 encrypted_seal.replace_range(encrypted_seal.len() - 1.., replacement);
381 }
382 NostrEventDraft::new(1_700_000_200, outer.kind, outer.tags, encrypted_seal)?
383 .sign_with_keys(wrapper_keys)
384 }
385
386 #[test]
387 fn round_trips_durable_and_ephemeral_envelopes() -> Result<(), SoftchatError> {
388 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
389 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
390 let rumor = direct_message_rumor(&alice, &bob)?;
391
392 for kind in [Nip59EnvelopeKind::Durable, Nip59EnvelopeKind::Ephemeral] {
393 let outer = alice.gift_wrap(&bob.public_key(), &rumor, kind, 1_700_172_800)?;
394 let received = bob.unwrap_gift_wrap(&outer)?;
395 assert_eq!(received.kind(), kind);
396 assert_eq!(received.outer_event_id(), outer.id());
397 assert_eq!(received.rumor(), &rumor);
398 assert_ne!(received.seal_event_id(), outer.id());
399 }
400 Ok(())
401 }
402
403 #[test]
404 fn unwraps_the_official_nip59_example() -> Result<(), Box<dyn std::error::Error>> {
405 let fixture: OfficialExample = serde_json::from_str(OFFICIAL_EXAMPLE)?;
406 let recipient = LocalIdentity::from_secret_hex(&fixture.recipient_secret)?;
407 let outer = SignedNostrEvent::from_json(&fixture.gift_wrap.to_string())?;
408 let expected_rumor = NostrRumor::from_json(&fixture.rumor.to_string())?;
409 let seal = SignedNostrEvent::from_json(&fixture.seal.to_string())?;
410
411 let received = recipient.unwrap_gift_wrap(&outer)?;
412
413 assert_eq!(received.kind(), Nip59EnvelopeKind::Durable);
414 assert_eq!(received.outer_event_id(), outer.id());
415 assert_eq!(received.seal_event_id(), seal.id());
416 assert_eq!(received.rumor(), &expected_rumor);
417 Ok(())
418 }
419
420 #[test]
421 fn uses_fresh_wrapper_keys_and_private_timestamp_bounds() -> Result<(), SoftchatError> {
422 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
423 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
424 let rumor = direct_message_rumor(&alice, &bob)?;
425 let now = 1_700_172_800;
426
427 let first = alice.gift_wrap(&bob.public_key(), &rumor, Nip59EnvelopeKind::Durable, now)?;
428 let second = alice.gift_wrap(&bob.public_key(), &rumor, Nip59EnvelopeKind::Durable, now)?;
429
430 assert_ne!(first.public_key(), second.public_key());
431 for outer in [&first, &second] {
432 assert!((now - MAX_NIP59_TIMESTAMP_TWEAK_SECONDS..=now).contains(&outer.created_at()));
433 }
434 Ok(())
435 }
436
437 #[test]
438 fn deterministic_hook_keeps_private_timestamps_independent() -> Result<(), SoftchatError> {
439 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
440 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
441 let rumor = direct_message_rumor(&alice, &bob)?;
442 let wrapper_secret =
443 SecretKey::from_hex(WRAPPER_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?;
444 let wrapper_keys = Keys::new(wrapper_secret);
445
446 let outer = alice.gift_wrap_with_parameters(
447 &bob.public_key(),
448 &rumor,
449 Nip59EnvelopeKind::Durable,
450 1_700_000_100,
451 1_700_000_200,
452 &wrapper_keys,
453 )?;
454 let encrypted_seal = Nip44EncryptedMessage::new(
455 outer.public_key(),
456 Nip44Payload::from_encoded(outer.content())?,
457 );
458 let seal = SignedNostrEvent::from_json(&bob.decrypt_utf8(&encrypted_seal)?)?;
459
460 assert_eq!(seal.created_at(), 1_700_000_100);
461 assert_eq!(outer.created_at(), 1_700_000_200);
462 assert_eq!(
463 outer.public_key(),
464 NostrPublicKey::from_inner(wrapper_keys.public_key())
465 );
466 Ok(())
467 }
468
469 #[test]
470 fn rejects_wrong_recipient_and_wrong_rumor_author() -> Result<(), SoftchatError> {
471 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
472 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
473 let wrapper = LocalIdentity::from_secret_hex(WRAPPER_SECRET)?;
474 let rumor = direct_message_rumor(&alice, &bob)?;
475 let outer = alice.gift_wrap(
476 &bob.public_key(),
477 &rumor,
478 Nip59EnvelopeKind::Durable,
479 1_700_172_800,
480 )?;
481
482 assert!(matches!(
483 wrapper.unwrap_gift_wrap(&outer),
484 Err(SoftchatError::InvalidNip59Envelope)
485 ));
486 assert!(matches!(
487 wrapper.gift_wrap(
488 &bob.public_key(),
489 &rumor,
490 Nip59EnvelopeKind::Durable,
491 1_700_172_800,
492 ),
493 Err(SoftchatError::Nip59EnvelopeCreationFailed)
494 ));
495 Ok(())
496 }
497
498 #[test]
499 fn rejects_invalid_routes_kinds_seals_ciphertext_ids_and_author_binding()
500 -> Result<(), SoftchatError> {
501 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
502 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
503 let carol = LocalIdentity::from_secret_hex(WRAPPER_SECRET)?;
504 let rumor = direct_message_rumor(&alice, &bob)?;
505 let wrapper_keys = Keys::new(
506 SecretKey::from_hex(WRAPPER_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?,
507 );
508 let recipient_tag = NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?;
509 let alert_tag = NostrTag::new(vec!["alert".to_owned(), "true".to_owned()])?;
510
511 let with_non_recipient_tag = custom_envelope(
512 &alice,
513 &bob,
514 &wrapper_keys,
515 &rumor.to_json()?,
516 Vec::new(),
517 OuterParameters {
518 kind: NostrEventKind::GIFT_WRAP,
519 tags: vec![recipient_tag.clone(), alert_tag.clone()],
520 tamper_ciphertext: false,
521 },
522 )?;
523 assert_eq!(
524 bob.unwrap_gift_wrap(&with_non_recipient_tag)?.rumor(),
525 &rumor
526 );
527
528 let duplicate_route = custom_envelope(
529 &alice,
530 &bob,
531 &wrapper_keys,
532 &rumor.to_json()?,
533 Vec::new(),
534 OuterParameters {
535 kind: NostrEventKind::GIFT_WRAP,
536 tags: vec![recipient_tag.clone(), recipient_tag.clone()],
537 tamper_ciphertext: false,
538 },
539 )?;
540 let uppercase_route = custom_envelope(
541 &alice,
542 &bob,
543 &wrapper_keys,
544 &rumor.to_json()?,
545 Vec::new(),
546 OuterParameters {
547 kind: NostrEventKind::GIFT_WRAP,
548 tags: vec![NostrTag::new(vec![
549 "p".to_owned(),
550 bob.public_key().to_hex().to_uppercase(),
551 ])?],
552 tamper_ciphertext: false,
553 },
554 )?;
555 let wrong_kind = custom_envelope(
556 &alice,
557 &bob,
558 &wrapper_keys,
559 &rumor.to_json()?,
560 Vec::new(),
561 OuterParameters {
562 kind: NostrEventKind::FILE_METADATA,
563 tags: vec![recipient_tag.clone()],
564 tamper_ciphertext: false,
565 },
566 )?;
567 let tagged_seal = custom_envelope(
568 &alice,
569 &bob,
570 &wrapper_keys,
571 &rumor.to_json()?,
572 vec![alert_tag],
573 OuterParameters {
574 kind: NostrEventKind::GIFT_WRAP,
575 tags: vec![recipient_tag.clone()],
576 tamper_ciphertext: false,
577 },
578 )?;
579 let tampered_ciphertext = custom_envelope(
580 &alice,
581 &bob,
582 &wrapper_keys,
583 &rumor.to_json()?,
584 Vec::new(),
585 OuterParameters {
586 kind: NostrEventKind::GIFT_WRAP,
587 tags: vec![recipient_tag.clone()],
588 tamper_ciphertext: true,
589 },
590 )?;
591
592 let mut invalid_id: serde_json::Value =
593 serde_json::from_str(&rumor.to_json()?).map_err(|_| SoftchatError::InvalidEventJson)?;
594 invalid_id["id"] = serde_json::Value::String("00".repeat(32));
595 let invalid_rumor_id = custom_envelope(
596 &alice,
597 &bob,
598 &wrapper_keys,
599 &invalid_id.to_string(),
600 Vec::new(),
601 OuterParameters {
602 kind: NostrEventKind::GIFT_WRAP,
603 tags: vec![recipient_tag.clone()],
604 tamper_ciphertext: false,
605 },
606 )?;
607
608 let spoofed_rumor = carol.create_rumor(NostrEventDraft::new(
609 1_700_000_000,
610 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
611 vec![recipient_tag.clone()],
612 "spoofed",
613 )?)?;
614 let author_mismatch = custom_envelope(
615 &alice,
616 &bob,
617 &wrapper_keys,
618 &spoofed_rumor.to_json()?,
619 Vec::new(),
620 OuterParameters {
621 kind: NostrEventKind::GIFT_WRAP,
622 tags: vec![recipient_tag],
623 tamper_ciphertext: false,
624 },
625 )?;
626
627 for invalid in [
628 duplicate_route,
629 uppercase_route,
630 wrong_kind,
631 tagged_seal,
632 tampered_ciphertext,
633 invalid_rumor_id,
634 author_mismatch,
635 ] {
636 assert!(matches!(
637 bob.unwrap_gift_wrap(&invalid),
638 Err(SoftchatError::InvalidNip59Envelope)
639 ));
640 }
641 Ok(())
642 }
643}