nula_core/nips/nip59.rs
1//! [NIP-59] Gift Wrap.
2//!
3//! Gift wrapping turns an unsigned *rumor* into a sealed, anonymously
4//! publishable event. It is the metadata-hiding envelope that NIP-17
5//! private direct messages and other privacy-sensitive flows ride on.
6//!
7//! # Pipeline
8//!
9//! ```text
10//! sender_keys, recipient_pk, rumor (UnsignedEvent, no sig)
11//! |
12//! v
13//! ┌─────────────────────────────┐
14//! │ Seal (kind 13) │
15//! │ - tags: [] │
16//! │ - content: nip44(rumor) │
17//! │ - signed by sender │
18//! │ - randomized created_at │
19//! └─────────────────────────────┘
20//! |
21//! v
22//! ┌─────────────────────────────┐
23//! │ GiftWrap (kind 1059) │
24//! │ - tags: [["p", recipient]] │
25//! │ - content: nip44(seal) │
26//! │ - signed by RANDOM key │
27//! │ - randomized created_at │
28//! └─────────────────────────────┘
29//! ```
30//!
31//! The outer signer is throw-away keymaterial, so a relay snooping the
32//! event sees neither the sender's identity nor the inner content. Only
33//! the recipient — who holds the private half of `recipient_pk` — can
34//! peel the two layers off.
35//!
36//! # Timestamp randomization
37//!
38//! Both the seal and the gift wrap pin a `created_at` value drawn
39//! uniformly from `[now - 2 days, now]`. This keeps relays from
40//! correlating outgoing wraps by their precise emission time. Use the
41//! [`wrap_with_timestamps`] entry point to plug in deterministic
42//! timestamps for tests.
43//!
44//! # Layering with NIP-17
45//!
46//! NIP-17 private DMs build a `kind 14` rumor and run it through this
47//! module without ever signing the rumor itself: the spec mandates that
48//! the inner event stay unsigned so leaks remain *deniable*.
49//!
50//! [NIP-59]: https://github.com/nostr-protocol/nips/blob/master/59.md
51
52use thiserror::Error;
53
54use super::nip44;
55use crate::event::{Event, EventBuilder, EventError, Kind, Tag, TagKind, Tags, UnsignedEvent};
56use crate::key::{Keys, PublicKey};
57use crate::types::{RelayUrl, Timestamp, TimestampError};
58use crate::util::JsonUtil;
59use crate::util::rng::{self, RngError};
60
61/// 2-day randomization window for `created_at` (in seconds).
62const TWO_DAYS_SECS: u64 = 2 * 24 * 60 * 60;
63
64/// Errors raised by the gift-wrap pipeline.
65#[derive(Debug, Error)]
66#[non_exhaustive]
67pub enum Nip59Error {
68 /// Wall clock could not be read.
69 #[error(transparent)]
70 Clock(#[from] TimestampError),
71 /// OS RNG failed.
72 #[error(transparent)]
73 Rng(#[from] RngError),
74 /// NIP-44 encryption / decryption failed.
75 #[error(transparent)]
76 Nip44(#[from] nip44::Nip44Error),
77 /// JSON encoding / decoding failed.
78 #[error("JSON serialization failed: {0}")]
79 Json(#[from] serde_json::Error),
80 /// An invariant of the gift-wrap pipeline was violated by an
81 /// unexpected upstream failure (e.g. a `Keys` signer returning
82 /// `SignerMismatch`, which is unreachable in the happy path).
83 /// The string carries the upstream description for triage.
84 #[error("gift-wrap internal failure: {0}")]
85 Internal(String),
86 /// Outer signature did not verify.
87 #[error(transparent)]
88 Event(#[from] EventError),
89 /// The wrapped event was not the expected kind.
90 #[error("expected kind {expected}, got {got}")]
91 UnexpectedKind {
92 /// What we asked for (`Kind::SEAL` or `Kind::GIFT_WRAP`).
93 expected: u16,
94 /// What the event actually carried.
95 got: u16,
96 },
97 /// The seal's `pubkey` did not match the rumor's `pubkey`.
98 ///
99 /// This is the spec-mandated impersonation defence (§Encrypting):
100 /// "Clients MUST verify if pubkey of the kind:13 is the same pubkey
101 /// on the kind:14, otherwise any sender can impersonate others by
102 /// simply changing the pubkey on kind:14."
103 #[error("seal pubkey does not match rumor pubkey (impersonation attempt)")]
104 PubkeyMismatch,
105}
106
107/// Build a NIP-59 *rumor*: an [`UnsignedEvent`] whose `pubkey` is set to
108/// the sender, all other fields filled, and `id` computed.
109///
110/// The rumor is **never signed**. Per NIP-17 (and the broader gift-wrap
111/// model), an unsigned rumor preserves deniability: a leaked rumor
112/// cannot be traced back to a signature, so the sender retains the
113/// option to disclaim it.
114///
115/// `created_at`, `kind`, `tags`, and `content` come from `template`.
116/// Missing fields default to `(now, kind, [], "")`.
117#[must_use]
118pub fn build_rumor(
119 sender: &Keys,
120 kind: Kind,
121 tags: Tags,
122 content: impl Into<String>,
123 created_at: Timestamp,
124) -> UnsignedEvent {
125 UnsignedEvent::new(*sender.public_key(), created_at, kind, tags, content)
126}
127
128/// Wrap a rumor into a [`Seal`](Kind::SEAL) event signed by `sender`.
129///
130/// `seal_created_at` controls the seal's outer timestamp; pass
131/// [`random_past_timestamp`] for production use.
132///
133/// # Errors
134///
135/// See [`Nip59Error`].
136pub fn create_seal(
137 sender: &Keys,
138 recipient: &PublicKey,
139 rumor: &UnsignedEvent,
140 seal_created_at: Timestamp,
141) -> Result<Event, Nip59Error> {
142 let rumor_json = rumor.try_to_json()?;
143 let ciphertext = nip44::encrypt(sender.secret_key(), recipient, &rumor_json)?;
144 let seal = EventBuilder::new(Kind::SEAL, ciphertext)
145 .created_at(seal_created_at)
146 .sign_with_keys(sender)
147 .map_err(|e| match e {
148 crate::event::EventBuilderError::Clock(c) => Nip59Error::Clock(c),
149 crate::event::EventBuilderError::Signer(s) => {
150 // `SignerMismatch` from a `Keys` signer is unreachable
151 // by construction but the function stays total via the
152 // dedicated `Internal` variant.
153 Nip59Error::Internal(format!("seal signing failed unexpectedly: {s}"))
154 }
155 })?;
156 Ok(seal)
157}
158
159/// Wrap a [`Seal`](Kind::SEAL) inside a [`GiftWrap`](Kind::GIFT_WRAP)
160/// signed by a fresh ephemeral key.
161///
162/// `wrap_created_at` controls the wrap's outer timestamp; pass
163/// [`random_past_timestamp`] for production use. `relay_hint` is
164/// optional and feeds the third element of the `p` tag.
165///
166/// # Errors
167///
168/// See [`Nip59Error`].
169pub fn create_gift_wrap(
170 seal: &Event,
171 recipient: &PublicKey,
172 relay_hint: Option<&RelayUrl>,
173 wrap_created_at: Timestamp,
174) -> Result<Event, Nip59Error> {
175 let ephemeral = Keys::generate().map_err(|e| match e {
176 crate::key::SecretKeyError::Rng(r) => Nip59Error::Rng(r),
177 // Other variants from `SecretKey::generate` never surface in
178 // the happy path; route through `Internal` for completeness.
179 other => Nip59Error::Internal(format!("ephemeral key generation failed: {other}")),
180 })?;
181
182 let seal_json = seal.try_to_json()?;
183 let ciphertext = nip44::encrypt(ephemeral.secret_key(), recipient, &seal_json)?;
184
185 let p_tag = Tag::with(
186 &TagKind::single_letter(crate::SingleLetterTag::lowercase(crate::event::Alphabet::P)),
187 relay_hint.map_or_else(
188 || vec![recipient.to_hex()],
189 |url| vec![recipient.to_hex(), url.as_str().to_owned()],
190 ),
191 );
192
193 let wrap = EventBuilder::new(Kind::GIFT_WRAP, ciphertext)
194 .created_at(wrap_created_at)
195 .tag(p_tag)
196 .sign_with_keys(&ephemeral)
197 .map_err(|e| match e {
198 crate::event::EventBuilderError::Clock(c) => Nip59Error::Clock(c),
199 crate::event::EventBuilderError::Signer(s) => {
200 Nip59Error::Internal(format!("wrap signing failed unexpectedly: {s}"))
201 }
202 })?;
203 Ok(wrap)
204}
205
206/// One-shot helper: build a rumor from `(kind, tags, content)`, seal it,
207/// and gift-wrap the seal to `recipient` with random timestamps.
208///
209/// `rumor_created_at` is normally the wall clock; the seal and wrap each
210/// pick their own timestamp uniformly from the past 2 days.
211///
212/// # Errors
213///
214/// See [`Nip59Error`]. Both the seal and wrap stages share the same error
215/// channel.
216pub fn wrap(
217 sender: &Keys,
218 recipient: &PublicKey,
219 rumor_kind: Kind,
220 rumor_tags: Tags,
221 rumor_content: impl Into<String>,
222 rumor_created_at: Timestamp,
223 relay_hint: Option<&RelayUrl>,
224) -> Result<Event, Nip59Error> {
225 let rumor = build_rumor(
226 sender,
227 rumor_kind,
228 rumor_tags,
229 rumor_content,
230 rumor_created_at,
231 );
232 let seal = create_seal(sender, recipient, &rumor, random_past_timestamp()?)?;
233 create_gift_wrap(&seal, recipient, relay_hint, random_past_timestamp()?)
234}
235
236/// Bundle of explicit timestamps used by [`wrap_with_timestamps`].
237///
238/// Production code should pass [`Timestamps::random_past`] or
239/// [`Timestamps::all_at`] for tests; mixing wall-clock-derived values
240/// with hand-picked ones across the three layers is intentionally
241/// awkward to discourage subtle bugs (e.g. picking the same value for
242/// `rumor` and `wrap` and accidentally leaking the rumor timestamp via
243/// the wrap).
244#[derive(Debug, Clone, Copy)]
245pub struct Timestamps {
246 /// Author-supplied `created_at` of the inner rumor.
247 pub rumor: Timestamp,
248 /// Outer `created_at` of the seal (kind 13).
249 pub seal: Timestamp,
250 /// Outer `created_at` of the gift wrap (kind 1059).
251 pub wrap: Timestamp,
252}
253
254impl Timestamps {
255 /// Pick all three timestamps at the same instant.
256 ///
257 /// Convenient for round-trip tests where leakage is irrelevant.
258 #[must_use]
259 pub const fn all_at(ts: Timestamp) -> Self {
260 Self {
261 rumor: ts,
262 seal: ts,
263 wrap: ts,
264 }
265 }
266
267 /// Wall-clock rumor + two independent `[now - 2 days, now]` draws
268 /// for the seal and wrap.
269 ///
270 /// # Errors
271 ///
272 /// Returns [`Nip59Error::Clock`] / [`Nip59Error::Rng`] if the wall clock or
273 /// OS RNG is unavailable.
274 pub fn random_past() -> Result<Self, Nip59Error> {
275 Ok(Self {
276 rumor: Timestamp::now()?,
277 seal: random_past_timestamp()?,
278 wrap: random_past_timestamp()?,
279 })
280 }
281}
282
283/// Same as [`wrap`] but every `created_at` is supplied explicitly.
284///
285/// Use [`Timestamps::all_at`] for deterministic round-trip tests and
286/// [`Timestamps::random_past`] for the production randomization rules.
287///
288/// # Errors
289///
290/// See [`Nip59Error`].
291pub fn wrap_with_timestamps(
292 sender: &Keys,
293 recipient: &PublicKey,
294 rumor_kind: Kind,
295 rumor_tags: Tags,
296 rumor_content: impl Into<String>,
297 timestamps: Timestamps,
298 relay_hint: Option<&RelayUrl>,
299) -> Result<Event, Nip59Error> {
300 let rumor = build_rumor(
301 sender,
302 rumor_kind,
303 rumor_tags,
304 rumor_content,
305 timestamps.rumor,
306 );
307 let seal = create_seal(sender, recipient, &rumor, timestamps.seal)?;
308 create_gift_wrap(&seal, recipient, relay_hint, timestamps.wrap)
309}
310
311/// Peel a [`GiftWrap`](Kind::GIFT_WRAP) and recover the inner rumor.
312///
313/// The function does **not** verify the gift wrap's outer signature; the
314/// outer signer is by design throw-away keymaterial, and a tampered
315/// outer signature would still produce ciphertext that the inner NIP-44
316/// MAC catches. Callers that *want* to enforce a wire-level signature
317/// check (e.g. a relay validating before forwarding) should call
318/// [`Event::verify`] separately on the wrap.
319///
320/// What we DO verify, in order:
321///
322/// 1. The wrap's `kind` is `1059`.
323/// 2. NIP-44 decryption of the wrap's content under
324/// `(recipient_secret, wrap.pubkey)` succeeds — implies the wrap was
325/// encrypted to us.
326/// 3. The decrypted seal parses as a kind-13 event and its outer
327/// signature verifies (the seal *is* the sender-signed layer, so its
328/// signature must hold).
329/// 4. NIP-44 decryption of the seal's content under
330/// `(recipient_secret, seal.pubkey)` succeeds.
331/// 5. The decrypted rumor's `pubkey` matches the seal's `pubkey`
332/// (impersonation defence per §Encrypting).
333///
334/// # Errors
335///
336/// See [`Nip59Error`]. Returns [`Nip59Error::Nip44`] on tampered ciphertext,
337/// [`Nip59Error::UnexpectedKind`] when either layer is wrong, [`Nip59Error::Event`]
338/// when the seal's signature does not verify, and
339/// [`Nip59Error::PubkeyMismatch`] when the rumor's author was rewritten by a
340/// malicious sender.
341pub fn unwrap(recipient: &Keys, gift_wrap: &Event) -> Result<UnsignedEvent, Nip59Error> {
342 if gift_wrap.kind != Kind::GIFT_WRAP {
343 return Err(Nip59Error::UnexpectedKind {
344 expected: Kind::GIFT_WRAP.as_u16(),
345 got: gift_wrap.kind.as_u16(),
346 });
347 }
348
349 // Layer 1: peel the wrap.
350 let seal_json = nip44::decrypt(
351 recipient.secret_key(),
352 &gift_wrap.pubkey,
353 &gift_wrap.content,
354 )?;
355 let seal: Event = Event::from_json(seal_json)?;
356 if seal.kind != Kind::SEAL {
357 return Err(Nip59Error::UnexpectedKind {
358 expected: Kind::SEAL.as_u16(),
359 got: seal.kind.as_u16(),
360 });
361 }
362 // The seal carries a real Schnorr signature from the sender; it
363 // must verify or downstream code would attribute the rumor to the
364 // wrong identity.
365 seal.verify()?;
366
367 // Layer 2: peel the seal.
368 let rumor_json = nip44::decrypt(recipient.secret_key(), &seal.pubkey, &seal.content)?;
369 let rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json)?;
370
371 // Spec defence: the rumor must claim authorship by the same key
372 // that signed the seal. Otherwise the sender could re-pubkey the
373 // rumor at will.
374 if rumor.pubkey != seal.pubkey {
375 return Err(Nip59Error::PubkeyMismatch);
376 }
377
378 Ok(rumor)
379}
380
381/// Pick a [`Timestamp`] uniformly from `[now - 2 days, now]`.
382///
383/// # Errors
384///
385/// Returns [`Nip59Error::Clock`] if the wall clock cannot be read or
386/// [`Nip59Error::Rng`] if the OS RNG is unavailable.
387pub fn random_past_timestamp() -> Result<Timestamp, Nip59Error> {
388 let now = Timestamp::now()?;
389 let mut bytes = [0u8; 8];
390 rng::fill_bytes(&mut bytes)?;
391 let offset = u64::from_le_bytes(bytes) % TWO_DAYS_SECS;
392 Ok(Timestamp::from_secs(now.as_secs().saturating_sub(offset)))
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::Keys;
399
400 fn keys_alice() -> Keys {
401 // Distinct, deterministic 32-byte fixture keys. Lowest non-zero
402 // bytes encode the human-readable handle so test failures point
403 // at a recognisable identity (`a1ce`, `b0b`, `ca8`, `ba1d`).
404 Keys::parse("000000000000000000000000000000000000000000000000000000000000a1ce").unwrap()
405 }
406
407 fn keys_bob() -> Keys {
408 Keys::parse("00000000000000000000000000000000000000000000000000000000000000b0").unwrap()
409 }
410
411 #[test]
412 fn wrap_round_trip_recovers_rumor() {
413 let alice = keys_alice();
414 let bob = keys_bob();
415 let now = Timestamp::from_secs(1_700_000_000);
416 let seal_ts = Timestamp::from_secs(1_699_900_000);
417 let wrap_ts = Timestamp::from_secs(1_699_800_000);
418
419 let wrap = wrap_with_timestamps(
420 &alice,
421 bob.public_key(),
422 Kind::PRIVATE_DIRECT_MESSAGE,
423 Tags::new(),
424 "secret hello",
425 Timestamps {
426 rumor: now,
427 seal: seal_ts,
428 wrap: wrap_ts,
429 },
430 None,
431 )
432 .unwrap();
433 wrap.verify().unwrap();
434 assert_eq!(wrap.kind, Kind::GIFT_WRAP);
435
436 let rumor = unwrap(&bob, &wrap).unwrap();
437 assert_eq!(rumor.kind, Kind::PRIVATE_DIRECT_MESSAGE);
438 assert_eq!(rumor.pubkey, *alice.public_key());
439 assert_eq!(rumor.content, "secret hello");
440 assert_eq!(rumor.created_at, now);
441 }
442
443 #[test]
444 fn wrap_picks_random_timestamps() {
445 let alice = keys_alice();
446 let bob = keys_bob();
447 let now = Timestamp::from_secs(1_700_000_000);
448
449 let wrap1 = wrap(
450 &alice,
451 bob.public_key(),
452 Kind::PRIVATE_DIRECT_MESSAGE,
453 Tags::new(),
454 "msg",
455 now,
456 None,
457 )
458 .unwrap();
459 // Wrap timestamp must be in `[now - 2 days, now]`.
460 assert!(wrap1.created_at <= Timestamp::now().unwrap());
461 }
462
463 #[test]
464 fn unwrap_rejects_wrong_kind() {
465 let bob = keys_bob();
466 let bogus = EventBuilder::text_note("not a wrap")
467 .created_at(Timestamp::from_secs(1))
468 .sign_with_keys(&bob)
469 .unwrap();
470 let err = unwrap(&bob, &bogus).unwrap_err();
471 assert!(matches!(
472 err,
473 Nip59Error::UnexpectedKind {
474 expected: 1059,
475 got: 1
476 }
477 ));
478 }
479
480 #[test]
481 fn unwrap_rejects_recipient_mismatch() {
482 let alice = keys_alice();
483 let bob = keys_bob();
484 let carol = Keys::parse("00000000000000000000000000000000000000000000000000000000000ca800")
485 .unwrap();
486 let now = Timestamp::from_secs(1_700_000_000);
487
488 // Wrap targeted at Bob.
489 let wrap_for_bob = wrap_with_timestamps(
490 &alice,
491 bob.public_key(),
492 Kind::PRIVATE_DIRECT_MESSAGE,
493 Tags::new(),
494 "for bob only",
495 Timestamps::all_at(now),
496 None,
497 )
498 .unwrap();
499
500 // Carol cannot decrypt — the NIP-44 MAC catches it.
501 let err = unwrap(&carol, &wrap_for_bob).unwrap_err();
502 assert!(matches!(err, Nip59Error::Nip44(_)));
503 }
504
505 #[test]
506 fn unwrap_detects_pubkey_substitution() {
507 // We construct a wrap whose seal claims one author but whose
508 // rumor claims another, and verify the impersonation defence
509 // surfaces it as `PubkeyMismatch`. Building the malicious wrap
510 // requires manual surgery: reuse the public surface to encrypt
511 // a tampered rumor under the seal's keys.
512 let alice = keys_alice();
513 let bob = keys_bob();
514 let mallory =
515 Keys::parse("00000000000000000000000000000000000000000000000000000000000ba1d0")
516 .unwrap();
517 let now = Timestamp::from_secs(1_700_000_000);
518
519 // Mallory builds a rumor *claiming* Alice as the author.
520 let tampered_rumor = UnsignedEvent::new(
521 *alice.public_key(),
522 now,
523 Kind::PRIVATE_DIRECT_MESSAGE,
524 Tags::new(),
525 "alice did NOT write this",
526 );
527 let tampered_rumor_json = tampered_rumor.try_to_json().unwrap();
528 // Mallory seals it with HER OWN key (so the seal pubkey is
529 // mallory, not alice — the spec says reject).
530 let ciphertext =
531 nip44::encrypt(mallory.secret_key(), bob.public_key(), &tampered_rumor_json).unwrap();
532 let seal = EventBuilder::new(Kind::SEAL, ciphertext)
533 .created_at(now)
534 .sign_with_keys(&mallory)
535 .unwrap();
536 let wrap_evt = create_gift_wrap(&seal, bob.public_key(), None, now).unwrap();
537
538 let err = unwrap(&bob, &wrap_evt).unwrap_err();
539 assert!(matches!(err, Nip59Error::PubkeyMismatch));
540 }
541
542 #[test]
543 fn random_past_timestamp_in_window() {
544 let now = Timestamp::now().unwrap();
545 for _ in 0..10 {
546 let ts = random_past_timestamp().unwrap();
547 assert!(ts <= now);
548 assert!(ts.as_secs() + TWO_DAYS_SECS >= now.as_secs());
549 }
550 }
551}