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:
@@ -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]
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user