add encrypted Nostr session sharing (#8922)

Signed-off-by: callebtc <93376500+callebtc@users.noreply.github.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
callebtc
2026-05-12 22:06:44 -05:00
committed by GitHub
parent 8c36ba86c6
commit dbbee1cdbf
17 changed files with 1469 additions and 27 deletions
+5
View File
@@ -39,6 +39,7 @@ aws-providers = [
cuda = ["local-inference", "candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
vulkan = ["local-inference", "llama-cpp-2/vulkan"]
rustls-tls = [
"dep:rustls",
"reqwest/rustls",
"rmcp/reqwest",
"sqlx/runtime-tokio-rustls",
@@ -59,6 +60,7 @@ native-tls = [
"oauth2/native-tls",
]
[lints]
workspace = true
@@ -194,6 +196,9 @@ goose-acp-macros = { path = "../goose-acp-macros" }
tower-http = { workspace = true, features = ["cors"] }
http-body-util = "0.1.3"
process-wrap = { version = "9.1.0", features = ["std"] }
nostr = { version = "0.44.2", features = ["nip44"] }
nostr-sdk = { version = "0.44.1", features = ["nip44"] }
rustls = { version = "0.23", features = ["aws_lc_rs"], optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
+1
View File
@@ -2,6 +2,7 @@ mod chat_history_search;
mod diagnostics;
pub mod extension_data;
mod legacy;
pub mod nostr_share;
pub mod session_manager;
pub use diagnostics::{
+370
View File
@@ -0,0 +1,370 @@
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use nostr::nips::nip19::{FromBech32, Nip19Event, ToBech32};
use nostr::nips::nip44;
use nostr::prelude::*;
use nostr_sdk::Client;
use crate::config::{Config, ConfigError};
pub const EVENT_KIND: u16 = 30278;
pub const CONFIG_RELAYS_KEY: &str = "GOOSE_NOSTR_RELAYS";
const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
"wss://relay.nostr.band",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NostrShare {
pub deeplink: String,
pub nevent: String,
pub event_id: String,
pub relays: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedShareLink {
pub nevent: String,
pub decryption_key: String,
}
#[async_trait]
pub trait NostrPublisher {
async fn publish(&self, event: Event, relays: &[String]) -> Result<()>;
}
#[async_trait]
pub trait NostrFetcher {
async fn fetch(&self, event_id: EventId, relays: &[String]) -> Result<Event>;
}
pub struct LiveNostrClient;
#[async_trait]
impl NostrPublisher for LiveNostrClient {
async fn publish(&self, event: Event, relays: &[String]) -> Result<()> {
install_rustls_crypto_provider();
let client = Client::default();
for relay in relays {
client
.add_relay(relay)
.await
.with_context(|| format!("Failed to add relay {relay}"))?;
}
client.try_connect(Duration::from_secs(8)).await;
let output = client
.send_event_to(relays.iter().map(String::as_str), &event)
.await
.context("Failed to publish session to Nostr relays")?;
client.shutdown().await;
if output.success.is_empty() {
return Err(anyhow!(
"Failed to publish session to any Nostr relay: {:?}",
output.failed
));
}
Ok(())
}
}
#[async_trait]
impl NostrFetcher for LiveNostrClient {
async fn fetch(&self, event_id: EventId, relays: &[String]) -> Result<Event> {
install_rustls_crypto_provider();
let client = Client::default();
for relay in relays {
client
.add_relay(relay)
.await
.with_context(|| format!("Failed to add relay {relay}"))?;
}
client.try_connect(Duration::from_secs(8)).await;
let filter = Filter::new()
.id(event_id)
.kind(Kind::Custom(EVENT_KIND))
.limit(1);
let events = client
.fetch_events_from(
relays.iter().map(String::as_str),
filter,
Duration::from_secs(10),
)
.await
.context("Failed to fetch shared session from Nostr relays")?;
client.shutdown().await;
events
.into_iter()
.next()
.ok_or_else(|| anyhow!("Shared session event not found"))
}
}
#[cfg(feature = "rustls-tls")]
fn install_rustls_crypto_provider() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
#[cfg(not(feature = "rustls-tls"))]
fn install_rustls_crypto_provider() {}
pub fn default_relays() -> Vec<String> {
DEFAULT_RELAYS
.iter()
.map(|relay| relay.to_string())
.collect()
}
pub fn relays_from_config(config: &Config) -> Vec<String> {
match config.get_param::<Vec<String>>(CONFIG_RELAYS_KEY) {
Ok(relays) if !relays.is_empty() => normalize_relays(relays),
Err(ConfigError::NotFound(_)) => default_relays(),
_ => default_relays(),
}
}
pub fn resolve_relays(cli_relays: Vec<String>, config: &Config) -> Vec<String> {
if cli_relays.is_empty() {
relays_from_config(config)
} else {
normalize_relays(cli_relays)
}
}
pub async fn publish_session_json(session_json: &str, relays: Vec<String>) -> Result<NostrShare> {
publish_session_json_with(session_json, relays, &LiveNostrClient).await
}
pub async fn publish_session_json_with<P>(
session_json: &str,
relays: Vec<String>,
publisher: &P,
) -> Result<NostrShare>
where
P: NostrPublisher + Sync,
{
let relays = normalize_relays(relays);
if relays.is_empty() {
return Err(anyhow!("At least one Nostr relay is required"));
}
let relay_urls = relays
.iter()
.map(|relay| RelayUrl::parse(relay))
.collect::<Result<Vec<_>, _>>()?;
let publish_keys = Keys::generate();
let encryption_key = SecretKey::generate();
let encryption_keys = Keys::new(encryption_key.clone());
let encrypted = nip44::encrypt(
&encryption_key,
&encryption_keys.public_key(),
session_json,
nip44::Version::V2,
)?;
let event = EventBuilder::new(Kind::Custom(EVENT_KIND), encrypted)
.tag(Tag::identifier(format!(
"goose-session-{}",
uuid::Uuid::now_v7()
)))
.tag(Tag::parse(["client", "goose"])?)
.sign_with_keys(&publish_keys)?;
publisher.publish(event.clone(), &relays).await?;
let nevent = Nip19Event::new(event.id)
.author(event.pubkey)
.kind(Kind::Custom(EVENT_KIND))
.relays(relay_urls)
.to_bech32()?;
let decryption_key = encryption_key.to_secret_hex();
let deeplink = build_deeplink(&nevent, &decryption_key);
Ok(NostrShare {
deeplink,
nevent,
event_id: event.id.to_hex(),
relays,
})
}
pub async fn import_session_json_from_deeplink(deeplink: &str) -> Result<String> {
import_session_json_from_deeplink_with(deeplink, &LiveNostrClient).await
}
pub async fn import_session_json_from_deeplink_with<F>(
deeplink: &str,
fetcher: &F,
) -> Result<String>
where
F: NostrFetcher + Sync,
{
let ParsedShareLink {
nevent,
decryption_key,
} = parse_deeplink(deeplink)?;
let event_ref = Nip19Event::from_bech32(&nevent)?;
let relays = event_ref
.relays
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
if relays.is_empty() {
return Err(anyhow!("Shared session link does not include any relays"));
}
let event = fetcher.fetch(event_ref.event_id, &relays).await?;
if event.kind != Kind::Custom(EVENT_KIND) {
return Err(anyhow!(
"Unexpected Nostr event kind: {}",
u16::from(event.kind)
));
}
let secret_key = SecretKey::parse(&decryption_key)?;
let encryption_keys = Keys::new(secret_key.clone());
nip44::decrypt(&secret_key, &encryption_keys.public_key(), event.content).map_err(Into::into)
}
pub fn build_deeplink(nevent: &str, decryption_key: &str) -> String {
format!(
"goose://sessions/nostr?nevent={}&key={}",
urlencoding::encode(nevent),
urlencoding::encode(decryption_key)
)
}
pub fn parse_deeplink(deeplink: &str) -> Result<ParsedShareLink> {
let parsed = url::Url::parse(deeplink).context("Invalid Goose session share link")?;
if parsed.scheme() != "goose"
|| parsed.host_str() != Some("sessions")
|| parsed.path() != "/nostr"
{
return Err(anyhow!("Invalid Goose Nostr session share link"));
}
let nevent = parsed
.query_pairs()
.find_map(|(key, value)| (key == "nevent").then(|| value.into_owned()))
.ok_or_else(|| anyhow!("Missing nevent parameter"))?;
let decryption_key = parsed
.query_pairs()
.find_map(|(key, value)| (key == "key").then(|| value.into_owned()))
.ok_or_else(|| anyhow!("Missing decryption key parameter"))?;
Ok(ParsedShareLink {
nevent,
decryption_key,
})
}
fn normalize_relays(relays: Vec<String>) -> Vec<String> {
let mut normalized = Vec::new();
for relay in relays {
let relay = relay.trim();
if relay.is_empty() || normalized.iter().any(|existing| existing == relay) {
continue;
}
normalized.push(relay.to_string());
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
struct RecordingPublisher {
event: Arc<Mutex<Option<Event>>>,
relays: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl NostrPublisher for RecordingPublisher {
async fn publish(&self, event: Event, relays: &[String]) -> Result<()> {
*self.event.lock().unwrap() = Some(event);
*self.relays.lock().unwrap() = relays.to_vec();
Ok(())
}
}
struct StaticFetcher(Event);
#[async_trait]
impl NostrFetcher for StaticFetcher {
async fn fetch(&self, _event_id: EventId, _relays: &[String]) -> Result<Event> {
Ok(self.0.clone())
}
}
#[tokio::test]
async fn publish_builds_deeplink_and_encrypted_kind_30278_event() {
let event = Arc::new(Mutex::new(None));
let relays = Arc::new(Mutex::new(Vec::new()));
let publisher = RecordingPublisher {
event: event.clone(),
relays: relays.clone(),
};
let share = publish_session_json_with(
r#"{"id":"session-id","conversation":{"messages":[]}}"#,
vec!["wss://relay.example".to_string()],
&publisher,
)
.await
.unwrap();
assert!(share.deeplink.starts_with("goose://sessions/nostr?"));
assert!(share.nevent.starts_with("nevent1"));
assert_eq!(share.relays, vec!["wss://relay.example"]);
assert_eq!(*relays.lock().unwrap(), vec!["wss://relay.example"]);
let event = event.lock().unwrap().clone().unwrap();
assert_eq!(event.kind, Kind::Custom(EVENT_KIND));
assert_ne!(
event.content,
r#"{"id":"session-id","conversation":{"messages":[]}}"#
);
}
#[tokio::test]
async fn publish_and_import_round_trips_session_json() {
let event = Arc::new(Mutex::new(None));
let publisher = RecordingPublisher {
event: event.clone(),
relays: Arc::new(Mutex::new(Vec::new())),
};
let json = r#"{"id":"session-id","name":"shared"}"#;
let share =
publish_session_json_with(json, vec!["wss://relay.example".to_string()], &publisher)
.await
.unwrap();
let fetched_event = event.lock().unwrap().clone().unwrap();
let imported =
import_session_json_from_deeplink_with(&share.deeplink, &StaticFetcher(fetched_event))
.await
.unwrap();
assert_eq!(imported, json);
}
#[test]
fn parses_deeplink() {
let parsed = parse_deeplink("goose://sessions/nostr?nevent=abc&key=def").unwrap();
assert_eq!(parsed.nevent, "abc");
assert_eq!(parsed.decryption_key, "def");
}
}