Skip to main content

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}