use thiserror::Error;
use super::nip44;
use crate::event::{Event, EventBuilder, EventError, Kind, Tag, TagKind, Tags, UnsignedEvent};
use crate::key::{Keys, PublicKey};
use crate::types::{RelayUrl, Timestamp, TimestampError};
use crate::util::JsonUtil;
use crate::util::rng::{self, RngError};
const TWO_DAYS_SECS: u64 = 2 * 24 * 60 * 60;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip59Error {
#[error(transparent)]
Clock(#[from] TimestampError),
#[error(transparent)]
Rng(#[from] RngError),
#[error(transparent)]
Nip44(#[from] nip44::Nip44Error),
#[error("JSON serialization failed: {0}")]
Json(#[from] serde_json::Error),
#[error("gift-wrap internal failure: {0}")]
Internal(String),
#[error(transparent)]
Event(#[from] EventError),
#[error("expected kind {expected}, got {got}")]
UnexpectedKind {
expected: u16,
got: u16,
},
#[error("seal pubkey does not match rumor pubkey (impersonation attempt)")]
PubkeyMismatch,
}
#[must_use]
pub fn build_rumor(
sender: &Keys,
kind: Kind,
tags: Tags,
content: impl Into<String>,
created_at: Timestamp,
) -> UnsignedEvent {
UnsignedEvent::new(*sender.public_key(), created_at, kind, tags, content)
}
pub fn create_seal(
sender: &Keys,
recipient: &PublicKey,
rumor: &UnsignedEvent,
seal_created_at: Timestamp,
) -> Result<Event, Nip59Error> {
let rumor_json = rumor.try_to_json()?;
let ciphertext = nip44::encrypt(sender.secret_key(), recipient, &rumor_json)?;
let seal = EventBuilder::new(Kind::SEAL, ciphertext)
.created_at(seal_created_at)
.sign_with_keys(sender)
.map_err(|e| match e {
crate::event::EventBuilderError::Clock(c) => Nip59Error::Clock(c),
crate::event::EventBuilderError::Signer(s) => {
Nip59Error::Internal(format!("seal signing failed unexpectedly: {s}"))
}
})?;
Ok(seal)
}
pub fn create_gift_wrap(
seal: &Event,
recipient: &PublicKey,
relay_hint: Option<&RelayUrl>,
wrap_created_at: Timestamp,
) -> Result<Event, Nip59Error> {
let ephemeral = Keys::generate().map_err(|e| match e {
crate::key::SecretKeyError::Rng(r) => Nip59Error::Rng(r),
other => Nip59Error::Internal(format!("ephemeral key generation failed: {other}")),
})?;
let seal_json = seal.try_to_json()?;
let ciphertext = nip44::encrypt(ephemeral.secret_key(), recipient, &seal_json)?;
let p_tag = Tag::with(
&TagKind::single_letter(crate::SingleLetterTag::lowercase(crate::event::Alphabet::P)),
relay_hint.map_or_else(
|| vec![recipient.to_hex()],
|url| vec![recipient.to_hex(), url.as_str().to_owned()],
),
);
let wrap = EventBuilder::new(Kind::GIFT_WRAP, ciphertext)
.created_at(wrap_created_at)
.tag(p_tag)
.sign_with_keys(&ephemeral)
.map_err(|e| match e {
crate::event::EventBuilderError::Clock(c) => Nip59Error::Clock(c),
crate::event::EventBuilderError::Signer(s) => {
Nip59Error::Internal(format!("wrap signing failed unexpectedly: {s}"))
}
})?;
Ok(wrap)
}
pub fn wrap(
sender: &Keys,
recipient: &PublicKey,
rumor_kind: Kind,
rumor_tags: Tags,
rumor_content: impl Into<String>,
rumor_created_at: Timestamp,
relay_hint: Option<&RelayUrl>,
) -> Result<Event, Nip59Error> {
let rumor = build_rumor(
sender,
rumor_kind,
rumor_tags,
rumor_content,
rumor_created_at,
);
let seal = create_seal(sender, recipient, &rumor, random_past_timestamp()?)?;
create_gift_wrap(&seal, recipient, relay_hint, random_past_timestamp()?)
}
#[derive(Debug, Clone, Copy)]
pub struct Timestamps {
pub rumor: Timestamp,
pub seal: Timestamp,
pub wrap: Timestamp,
}
impl Timestamps {
#[must_use]
pub const fn all_at(ts: Timestamp) -> Self {
Self {
rumor: ts,
seal: ts,
wrap: ts,
}
}
pub fn random_past() -> Result<Self, Nip59Error> {
Ok(Self {
rumor: Timestamp::now()?,
seal: random_past_timestamp()?,
wrap: random_past_timestamp()?,
})
}
}
pub fn wrap_with_timestamps(
sender: &Keys,
recipient: &PublicKey,
rumor_kind: Kind,
rumor_tags: Tags,
rumor_content: impl Into<String>,
timestamps: Timestamps,
relay_hint: Option<&RelayUrl>,
) -> Result<Event, Nip59Error> {
let rumor = build_rumor(
sender,
rumor_kind,
rumor_tags,
rumor_content,
timestamps.rumor,
);
let seal = create_seal(sender, recipient, &rumor, timestamps.seal)?;
create_gift_wrap(&seal, recipient, relay_hint, timestamps.wrap)
}
pub fn unwrap(recipient: &Keys, gift_wrap: &Event) -> Result<UnsignedEvent, Nip59Error> {
if gift_wrap.kind != Kind::GIFT_WRAP {
return Err(Nip59Error::UnexpectedKind {
expected: Kind::GIFT_WRAP.as_u16(),
got: gift_wrap.kind.as_u16(),
});
}
let seal_json = nip44::decrypt(
recipient.secret_key(),
&gift_wrap.pubkey,
&gift_wrap.content,
)?;
let seal: Event = Event::from_json(seal_json)?;
if seal.kind != Kind::SEAL {
return Err(Nip59Error::UnexpectedKind {
expected: Kind::SEAL.as_u16(),
got: seal.kind.as_u16(),
});
}
seal.verify()?;
let rumor_json = nip44::decrypt(recipient.secret_key(), &seal.pubkey, &seal.content)?;
let rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json)?;
if rumor.pubkey != seal.pubkey {
return Err(Nip59Error::PubkeyMismatch);
}
Ok(rumor)
}
pub fn random_past_timestamp() -> Result<Timestamp, Nip59Error> {
let now = Timestamp::now()?;
let mut bytes = [0u8; 8];
rng::fill_bytes(&mut bytes)?;
let offset = u64::from_le_bytes(bytes) % TWO_DAYS_SECS;
Ok(Timestamp::from_secs(now.as_secs().saturating_sub(offset)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys_alice() -> Keys {
Keys::parse("000000000000000000000000000000000000000000000000000000000000a1ce").unwrap()
}
fn keys_bob() -> Keys {
Keys::parse("00000000000000000000000000000000000000000000000000000000000000b0").unwrap()
}
#[test]
fn wrap_round_trip_recovers_rumor() {
let alice = keys_alice();
let bob = keys_bob();
let now = Timestamp::from_secs(1_700_000_000);
let seal_ts = Timestamp::from_secs(1_699_900_000);
let wrap_ts = Timestamp::from_secs(1_699_800_000);
let wrap = wrap_with_timestamps(
&alice,
bob.public_key(),
Kind::PRIVATE_DIRECT_MESSAGE,
Tags::new(),
"secret hello",
Timestamps {
rumor: now,
seal: seal_ts,
wrap: wrap_ts,
},
None,
)
.unwrap();
wrap.verify().unwrap();
assert_eq!(wrap.kind, Kind::GIFT_WRAP);
let rumor = unwrap(&bob, &wrap).unwrap();
assert_eq!(rumor.kind, Kind::PRIVATE_DIRECT_MESSAGE);
assert_eq!(rumor.pubkey, *alice.public_key());
assert_eq!(rumor.content, "secret hello");
assert_eq!(rumor.created_at, now);
}
#[test]
fn wrap_picks_random_timestamps() {
let alice = keys_alice();
let bob = keys_bob();
let now = Timestamp::from_secs(1_700_000_000);
let wrap1 = wrap(
&alice,
bob.public_key(),
Kind::PRIVATE_DIRECT_MESSAGE,
Tags::new(),
"msg",
now,
None,
)
.unwrap();
assert!(wrap1.created_at <= Timestamp::now().unwrap());
}
#[test]
fn unwrap_rejects_wrong_kind() {
let bob = keys_bob();
let bogus = EventBuilder::text_note("not a wrap")
.created_at(Timestamp::from_secs(1))
.sign_with_keys(&bob)
.unwrap();
let err = unwrap(&bob, &bogus).unwrap_err();
assert!(matches!(
err,
Nip59Error::UnexpectedKind {
expected: 1059,
got: 1
}
));
}
#[test]
fn unwrap_rejects_recipient_mismatch() {
let alice = keys_alice();
let bob = keys_bob();
let carol = Keys::parse("00000000000000000000000000000000000000000000000000000000000ca800")
.unwrap();
let now = Timestamp::from_secs(1_700_000_000);
let wrap_for_bob = wrap_with_timestamps(
&alice,
bob.public_key(),
Kind::PRIVATE_DIRECT_MESSAGE,
Tags::new(),
"for bob only",
Timestamps::all_at(now),
None,
)
.unwrap();
let err = unwrap(&carol, &wrap_for_bob).unwrap_err();
assert!(matches!(err, Nip59Error::Nip44(_)));
}
#[test]
fn unwrap_detects_pubkey_substitution() {
let alice = keys_alice();
let bob = keys_bob();
let mallory =
Keys::parse("00000000000000000000000000000000000000000000000000000000000ba1d0")
.unwrap();
let now = Timestamp::from_secs(1_700_000_000);
let tampered_rumor = UnsignedEvent::new(
*alice.public_key(),
now,
Kind::PRIVATE_DIRECT_MESSAGE,
Tags::new(),
"alice did NOT write this",
);
let tampered_rumor_json = tampered_rumor.try_to_json().unwrap();
let ciphertext =
nip44::encrypt(mallory.secret_key(), bob.public_key(), &tampered_rumor_json).unwrap();
let seal = EventBuilder::new(Kind::SEAL, ciphertext)
.created_at(now)
.sign_with_keys(&mallory)
.unwrap();
let wrap_evt = create_gift_wrap(&seal, bob.public_key(), None, now).unwrap();
let err = unwrap(&bob, &wrap_evt).unwrap_err();
assert!(matches!(err, Nip59Error::PubkeyMismatch));
}
#[test]
fn random_past_timestamp_in_window() {
let now = Timestamp::now().unwrap();
for _ in 0..10 {
let ts = random_past_timestamp().unwrap();
assert!(ts <= now);
assert!(ts.as_secs() + TWO_DAYS_SECS >= now.as_secs());
}
}
}