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
+35 -2
View File
@@ -543,6 +543,28 @@ enum SessionCommand {
default_value = "markdown"
)]
format: String,
#[arg(
long = "nostr",
help = "Publish the JSON session export as an encrypted Nostr event and print a Goose share link"
)]
nostr: bool,
#[arg(
long = "relay",
value_name = "RELAY",
help = "Nostr relay URL to publish to (can be specified multiple times)",
action = clap::ArgAction::Append
)]
relays: Vec<String>,
},
#[command(about = "Import a session from JSON or an encrypted Nostr share link")]
Import {
#[arg(help = "Path to a JSON session export, or a goose://sessions/nostr share link")]
input: String,
#[arg(long = "nostr", help = "Treat input as an encrypted Nostr share link")]
nostr: bool,
},
#[command(name = "diagnostics")]
Diagnostics {
@@ -1227,6 +1249,8 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
identifier,
output,
format,
nostr,
relays,
} => {
let session_manager = SessionManager::instance();
let session_identifier = if let Some(id) = identifier {
@@ -1244,8 +1268,17 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
}
}
};
crate::commands::session::handle_session_export(session_identifier, output, format)
.await?;
crate::commands::session::handle_session_export(
session_identifier,
output,
format,
nostr,
relays,
)
.await?;
}
SessionCommand::Import { input, nostr } => {
crate::commands::session::handle_session_import(input, nostr).await?;
}
SessionCommand::Diagnostics { identifier, output } => {
let session_manager = SessionManager::instance();
+46 -1
View File
@@ -3,7 +3,8 @@ use anyhow::{Context, Result};
use cliclack::{confirm, multiselect, select};
use etcetera::home_dir;
use goose::session::{generate_diagnostics, Session, SessionManager};
use goose::config::Config;
use goose::session::{generate_diagnostics, nostr_share, Session, SessionManager, SessionType};
use goose::utils::safe_truncate;
use regex::Regex;
use std::fs;
@@ -216,6 +217,8 @@ pub async fn handle_session_export(
session_id: String,
output_path: Option<PathBuf>,
format: String,
nostr: bool,
relays: Vec<String>,
) -> Result<()> {
let session_manager = SessionManager::instance();
let session = match session_manager.get_session(&session_id, true).await {
@@ -241,6 +244,29 @@ pub async fn handle_session_export(
_ => return Err(anyhow::anyhow!("Unsupported format: {}", format)),
};
if nostr {
if format != "json" {
return Err(anyhow::anyhow!(
"Nostr session sharing only supports --format json"
));
}
if output_path.is_some() {
return Err(anyhow::anyhow!(
"Nostr session sharing cannot be combined with --output"
));
}
let relays = nostr_share::resolve_relays(relays, Config::global());
let share = nostr_share::publish_session_json(&output, relays).await?;
println!("Session published to Nostr relays:");
for relay in &share.relays {
println!("- {}", relay);
}
println!("\nShare link:");
println!("{}", share.deeplink);
return Ok(());
}
if let Some(output_path) = output_path {
fs::write(&output_path, output).with_context(|| {
format!("Failed to write to output file: {}", output_path.display())
@@ -253,6 +279,25 @@ pub async fn handle_session_export(
Ok(())
}
pub async fn handle_session_import(input: String, nostr: bool) -> Result<()> {
let json = if nostr || input.starts_with("goose://sessions/nostr") {
nostr_share::import_session_json_from_deeplink(&input).await?
} else {
fs::read_to_string(&input)
.with_context(|| format!("Failed to read session import file: {input}"))?
};
let session_manager = SessionManager::instance();
let session = session_manager
.import_session(&json, Some(SessionType::User))
.await?;
println!("Session imported:");
println!("{} - {}", session.id, session.name);
Ok(())
}
pub async fn handle_diagnostics(session_id: &str, output_path: Option<PathBuf>) -> Result<()> {
println!(
"Generating diagnostics bundle for session '{}'...",
+5
View File
@@ -443,6 +443,8 @@ derive_utoipa!(IconTheme as IconThemeSchema);
super::routes::session::delete_session,
super::routes::session::export_session,
super::routes::session::import_session,
super::routes::session::share_session_nostr,
super::routes::session::import_session_nostr,
super::routes::session::update_session_user_recipe_values,
super::routes::session::fork_session,
super::routes::session::get_session_extensions,
@@ -512,6 +514,9 @@ derive_utoipa!(IconTheme as IconThemeSchema);
super::routes::session_events::SessionReplyResponse,
super::routes::session_events::CancelRequest,
super::routes::session::ImportSessionRequest,
super::routes::session::ShareSessionNostrRequest,
super::routes::session::ShareSessionNostrResponse,
super::routes::session::ImportSessionNostrRequest,
super::routes::session::SessionListResponse,
super::routes::session::UpdateSessionNameRequest,
super::routes::session::UpdateSessionUserRecipeValuesRequest,
+104
View File
@@ -11,6 +11,7 @@ use axum::{
};
use goose::agents::ExtensionConfig;
use goose::recipe::Recipe;
use goose::session::nostr_share;
use goose::session::session_manager::{SessionInsights, SessionType};
use goose::session::{EnabledExtensionsState, Session};
use serde::{Deserialize, Serialize};
@@ -50,6 +51,28 @@ pub struct ImportSessionRequest {
json: String,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ShareSessionNostrRequest {
#[serde(default)]
relays: Vec<String>,
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ShareSessionNostrResponse {
deeplink: String,
nevent: String,
event_id: String,
relays: Vec<String>,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportSessionNostrRequest {
deeplink: String,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ForkRequest {
@@ -364,6 +387,79 @@ async fn import_session(
Ok(Json(session))
}
#[utoipa::path(
post,
path = "/sessions/{session_id}/share/nostr",
request_body = ShareSessionNostrRequest,
params(
("session_id" = String, Path, description = "Unique identifier for the session")
),
responses(
(status = 200, description = "Session shared to Nostr successfully", body = ShareSessionNostrResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn share_session_nostr(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
Json(request): Json<ShareSessionNostrRequest>,
) -> Result<Json<ShareSessionNostrResponse>, StatusCode> {
let exported = state
.session_manager()
.export_session(&session_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let relays = nostr_share::resolve_relays(request.relays, goose::config::Config::global());
let share = nostr_share::publish_session_json(&exported, relays)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ShareSessionNostrResponse {
deeplink: share.deeplink,
nevent: share.nevent,
event_id: share.event_id,
relays: share.relays,
}))
}
#[utoipa::path(
post,
path = "/sessions/import/nostr",
request_body = ImportSessionNostrRequest,
responses(
(status = 200, description = "Nostr shared session imported successfully", body = Session),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 400, description = "Bad request - Invalid Nostr share link"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn import_session_nostr(
State(state): State<Arc<AppState>>,
Json(request): Json<ImportSessionNostrRequest>,
) -> Result<Json<Session>, StatusCode> {
let json = nostr_share::import_session_json_from_deeplink(&request.deeplink)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
let session = state
.session_manager()
.import_session(&json, Some(SessionType::User))
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
Ok(Json(session))
}
#[utoipa::path(
post,
path = "/sessions/{session_id}/fork",
@@ -505,10 +601,18 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/sessions/{session_id}", get(get_session))
.route("/sessions/{session_id}", delete(delete_session))
.route("/sessions/{session_id}/export", get(export_session))
.route(
"/sessions/{session_id}/share/nostr",
post(share_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route(
"/sessions/import",
post(import_session).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route(
"/sessions/import/nostr",
post(import_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
)
.route("/sessions/insights", get(get_session_insights))
.route("/sessions/{session_id}/name", put(update_session_name))
.route(
+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");
}
}