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:
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());
+ }
}