notedeck

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

commit 0cc918a0bb5eccdbc2364a33ff6fb590407545ad
parent 40beb3282b8ec8cb50f66eb25d43ccea2408d897
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Feb 2026 17:30:49 -0800

enable PNS-wrapped relay publishing for AI conversation events

All kind-1988/1989/31988 events are now encrypted with NIP-PNS
(kind-1080 envelope) before being sent to relays. Adds wrap_pns()
helper that encrypts inner event JSON and signs with derived PNS
keypair.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 38++++++++++++++++++++++++--------------
Mcrates/notedeck_dave/src/session_events.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 71 insertions(+), 14 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1487,20 +1487,30 @@ impl notedeck::App for Dave { self.check_interrupt_timeout(); // Process incoming AI responses for all sessions - let (sessions_needing_send, _events_to_publish) = self.process_events(ctx); - - // TODO: Publish events to relay pool for remote session control. - // Disabled until NIP-PNS wrapping is implemented — we must not - // broadcast plaintext AI conversation events to relays. - // - // let pending = std::mem::take(&mut self.pending_relay_events); - // for event in events_to_publish.iter().chain(pending.iter()) { - // match enostr::ClientMessage::event_json(event.note_json.clone()) { - // Ok(msg) => ctx.pool.send(&msg), - // Err(e) => tracing::warn!("failed to build relay message: {:?}", e), - // } - // } - self.pending_relay_events.clear(); + let (sessions_needing_send, events_to_publish) = self.process_events(ctx); + + // PNS-wrap and publish events to relays + let pending = std::mem::take(&mut self.pending_relay_events); + let all_events = events_to_publish.iter().chain(pending.iter()); + if let Some(sk) = ctx + .accounts + .get_selected_account() + .keypair() + .secret_key + { + let pns_keys = enostr::pns::derive_pns_keys(&sk.secret_bytes()); + for event in all_events { + match session_events::wrap_pns(&event.note_json, &pns_keys) { + Ok(pns_json) => { + match enostr::ClientMessage::event_json(pns_json) { + Ok(msg) => ctx.pool.send(&msg), + Err(e) => tracing::warn!("failed to build relay message: {:?}", e), + } + } + Err(e) => tracing::warn!("failed to PNS-wrap event: {}", e), + } + } + } // Poll for remote permission responses from relay events. // These arrive as kind-1988 events with role=permission_response, diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -56,6 +56,37 @@ impl BuiltEvent { } } +/// Wrap an inner event in a kind-1080 PNS envelope for relay publishing. +/// +/// Encrypts the inner event JSON with the PNS conversation key and signs +/// the outer event with the PNS keypair. Returns the kind-1080 event JSON. +pub fn wrap_pns( + inner_json: &str, + pns_keys: &enostr::pns::PnsKeys, +) -> Result<String, EventBuildError> { + let ciphertext = enostr::pns::encrypt(&pns_keys.conversation_key, inner_json) + .map_err(|e| EventBuildError::Serialize(format!("PNS encrypt: {e}")))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let pns_secret = pns_keys.keypair.secret_key.secret_bytes(); + + let note = NoteBuilder::new() + .kind(enostr::pns::PNS_KIND) + .content(&ciphertext) + .options(NoteBuildOptions::default()) + .created_at(now) + .sign(&pns_secret) + .build() + .ok_or_else(|| EventBuildError::Build("PNS NoteBuilder::build returned None".into()))?; + + note.json() + .map_err(|e| EventBuildError::Serialize(format!("PNS json: {e:?}"))) +} + /// Maintains threading state across a session's events. pub struct ThreadingState { /// Maps JSONL uuid → nostr note ID (32 bytes). @@ -1191,4 +1222,20 @@ mod tests { assert!(json.contains("working")); assert!(json.contains("/tmp/project")); } + + #[test] + fn test_wrap_pns() { + let sk = test_secret_key(); + let pns_keys = enostr::pns::derive_pns_keys(&sk); + + let inner = r#"{"kind":1988,"content":"hello","tags":[],"created_at":0,"pubkey":"abc","id":"def","sig":"ghi"}"#; + let wrapped = wrap_pns(inner, &pns_keys).unwrap(); + + // Outer event should be kind 1080 + assert!(wrapped.contains("1080")); + // Should NOT contain the plaintext inner content + assert!(!wrapped.contains("hello")); + // Should be valid JSON + assert!(serde_json::from_str::<serde_json::Value>(&wrapped).is_ok()); + } }