notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit abef4659a8f2d6decb774795226b956535704784
parent 38743a5116fe8ba9dfd3e07347326d72069340f0
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu, 18 Dec 2025 17:45:30 -0500

feat(nip17): Nip17 helpers

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck_messages/src/lib.rs | 1+
Acrates/notedeck_messages/src/nip17/mod.rs | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 219 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_messages/src/lib.rs b/crates/notedeck_messages/src/lib.rs @@ -1,5 +1,6 @@ pub mod cache; pub mod convo_renderable; +pub mod nip17; use notedeck::{App, AppContext, AppResponse}; diff --git a/crates/notedeck_messages/src/nip17/mod.rs b/crates/notedeck_messages/src/nip17/mod.rs @@ -0,0 +1,218 @@ +use enostr::{FullKeypair, Pubkey, SecretKey}; +pub use nostr::secp256k1::rand::rngs::OsRng; +use nostr::secp256k1::rand::Rng; +use nostr::{ + event::{EventBuilder, Kind, Tag}, + key::PublicKey, + nips::nip44, + util::JsonUtil, +}; +use nostrdb::{Filter, FilterBuilder, Note, NoteBuilder}; +use notedeck::get_p_tags; + +fn build_rumor_json( + message: &str, + participants: &[Pubkey], + sender_pubkey: &Pubkey, +) -> Option<String> { + let sender = nostrcrate_pk(sender_pubkey)?; + let mut tags = Vec::new(); + for participant in participants { + if let Some(pk) = nostrcrate_pk(participant) { + tags.push(Tag::public_key(pk)); + } else { + tracing::warn!("invalid participant {}", participant); + } + } + + let builder = EventBuilder::new(Kind::PrivateDirectMessage, message).tags(tags); + Some(builder.build(sender).as_json()) +} + +pub fn giftwrap_message( + rng: &mut OsRng, + sender_secret: &SecretKey, + recipient: &Pubkey, + rumor_json: &str, +) -> Option<String> { + let Some(recipient_pk) = nostrcrate_pk(recipient) else { + tracing::warn!("failed to convert recipient pubkey {}", recipient); + return None; + }; + + let encrypted_rumor = match nip44::encrypt_with_rng( + rng, + sender_secret, + &recipient_pk, + rumor_json, + nip44::Version::V2, + ) { + Ok(payload) => payload, + Err(err) => { + tracing::error!("failed to encrypt rumor for {recipient}: {err}"); + return None; + } + }; + + let seal_created = randomized_timestamp(rng); + let Some(seal_json) = build_seal_json(&encrypted_rumor, sender_secret, seal_created) else { + tracing::error!("failed to build seal for recipient {}", recipient); + return None; + }; + + let wrap_keys = FullKeypair::generate(); + let encrypted_seal = match nip44::encrypt_with_rng( + rng, + &wrap_keys.secret_key, + &recipient_pk, + &seal_json, + nip44::Version::V2, + ) { + Ok(payload) => payload, + Err(err) => { + tracing::error!("failed to encrypt seal for wrap: {err}"); + return None; + } + }; + + let wrap_created = randomized_timestamp(rng); + build_giftwrap_json(&encrypted_seal, &wrap_keys, recipient, wrap_created) +} + +fn build_seal_json( + content_ciphertext: &str, + sender_secret: &SecretKey, + created_at: u64, +) -> Option<String> { + let builder = NoteBuilder::new() + .kind(13) + .content(content_ciphertext) + .created_at(created_at); + + builder + .sign(&sender_secret.secret_bytes()) + .build()? + .json() + .ok() +} + +fn build_giftwrap_json( + content: &str, + wrap_keys: &FullKeypair, + recipient: &Pubkey, + created_at: u64, +) -> Option<String> { + let builder = NoteBuilder::new() + .kind(1059) + .content(content) + .created_at(created_at) + .start_tag() + .tag_str("p") + .tag_str(&recipient.hex()); + + builder + .sign(&wrap_keys.secret_key.secret_bytes()) + .build()? + .json() + .ok() +} + +fn nostrcrate_pk(pk: &Pubkey) -> Option<PublicKey> { + PublicKey::from_slice(pk.bytes()).ok() +} + +fn current_timestamp() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn randomized_timestamp(rng: &mut OsRng) -> u64 { + const MAX_SKEW_SECS: u64 = 2 * 24 * 60 * 60; + let now = current_timestamp(); + let tweak = rng.gen_range(0..=MAX_SKEW_SECS); + now.saturating_sub(tweak) +} + +pub fn get_participants<'a>(note: &Note<'a>) -> Vec<&'a [u8; 32]> { + let mut participants = get_p_tags(note); + let chat_message_sender = note.pubkey(); + if !participants.contains(&chat_message_sender) { + // the chat message sender must be in the participants set + participants.push(chat_message_sender); + } + participants +} + +pub fn conversation_filter(cur_acc: &Pubkey) -> Vec<Filter> { + vec![ + FilterBuilder::new() + .kinds([14]) + .pubkey([cur_acc.bytes()]) + .build(), + FilterBuilder::new() + .kinds([14]) + .authors([cur_acc.bytes()]) + .build(), + ] +} + +/// Unfortunately this gives an OR across participants +pub fn chatroom_filter(participants: Vec<&[u8; 32]>, me: &[u8; 32]) -> Vec<Filter> { + vec![FilterBuilder::new() + .kinds([14]) + .authors(participants.clone()) + .pubkey([me]) + .build()] +} + +// easily retrievable from Note<'a> +pub struct Nip17ChatMessage<'a> { + pub sender: &'a [u8; 32], + pub p_tags: Vec<&'a [u8; 32]>, + pub subject: Option<&'a str>, + pub reply_to: Option<&'a [u8; 32]>, // NoteId + pub message: &'a str, + pub created_at: u64, +} + +pub fn parse_chat_message<'a>(note: &Note<'a>) -> Option<Nip17ChatMessage<'a>> { + if note.kind() != 14 { + return None; + } + + let mut p_tags = Vec::new(); + let mut subject = None; + let mut reply_to = None; + + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + let Some(first) = tag.get_str(0) else { + continue; + }; + + if first == "p" { + if let Some(id) = tag.get_id(1) { + p_tags.push(id); + } + } else if first == "subject" { + subject = tag.get_str(1); + } else if first == "e" { + reply_to = tag.get_id(1); + } + } + + Some(Nip17ChatMessage { + sender: note.pubkey(), + p_tags, + subject, + reply_to, + message: note.content(), + created_at: note.created_at(), + }) +}