diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index ae9e7ef13f..b413ab9319 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -77,6 +77,8 @@ pub const KIND_READ_STATE: u32 = 30078; pub const KIND_AUTH: u32 = 22242; /// BUD-01: Blossom upload auth (used in upload.rs, not stored). pub const KIND_BLOSSOM_AUTH: u32 = 24242; +/// Buzz custom one-time identity binding proof (ephemeral, not stored). +pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243; /// NIP-98: HTTP auth event (used in nip98.rs, not stored). pub const KIND_HTTP_AUTH: u32 = 27235; diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2afa45c443..c4ebf78ba5 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -7,6 +7,7 @@ use tauri::State; use crate::{ app_state::AppState, models::IdentityInfo, + nostr_bind, relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override}, }; @@ -204,6 +205,83 @@ pub fn import_identity( }) } +fn nostr_bind_tag(name: &str, value: &str) -> Result { + Tag::parse(vec![name, value]).map_err(|error| format!("{name} tag failed: {error}")) +} + +fn build_nostr_identity_binding_event( + keys: &Keys, + challenge_id: &str, + nonce: &str, + verification_code: &str, + origin: &str, + expires_at: &str, +) -> Result { + nostr_bind::validate_signing_request( + challenge_id, + nonce, + verification_code, + origin, + expires_at, + )?; + + let tags = vec![ + nostr_bind_tag("challenge_id", challenge_id)?, + nostr_bind_tag("nonce", nonce)?, + nostr_bind_tag("verification_code", verification_code)?, + nostr_bind_tag("audience", nostr_bind::AUDIENCE)?, + nostr_bind_tag("action", nostr_bind::ACTION)?, + nostr_bind_tag("protocol", nostr_bind::PROTOCOL)?, + nostr_bind_tag("version", nostr_bind::VERSION)?, + nostr_bind_tag("origin", origin)?, + nostr_bind_tag("expires_at", expires_at)?, + ]; + + EventBuilder::new(Kind::Custom(nostr_bind::KIND), nostr_bind::CONTENT) + .tags(tags) + .sign_with_keys(keys) + .map_err(|error| format!("sign failed: {error}")) +} + +#[tauri::command] +pub async fn sign_nostr_identity_binding( + challenge_id: String, + nonce: String, + verification_code: String, + origin: String, + expires_at: String, + state: State<'_, AppState>, +) -> Result { + nostr_bind::validate_signing_request( + &challenge_id, + &nonce, + &verification_code, + &origin, + &expires_at, + )?; + + let keys = state + .keys + .lock() + .map_err(|error| error.to_string())? + .clone(); + + tauri::async_runtime::spawn_blocking(move || { + let event = build_nostr_identity_binding_event( + &keys, + &challenge_id, + &nonce, + &verification_code, + &origin, + &expires_at, + )?; + + Ok(event.as_json()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + #[tauri::command] pub async fn create_auth_event( challenge: String, @@ -269,3 +347,88 @@ pub async fn nip44_decrypt_from_self( .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } + +#[cfg(test)] +mod nostr_identity_binding_tests { + use super::build_nostr_identity_binding_event; + use crate::nostr_bind; + use nostr::{JsonUtil, Keys}; + + fn tag_values(event: &nostr::Event) -> Vec> { + event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect() + } + + #[test] + fn build_nostr_identity_binding_event_signs_exact_shape() { + let keys = Keys::generate(); + let event = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "123456", + "https://example.com", + "2999-01-01T00:00:00Z", + ) + .unwrap(); + + assert_eq!(event.kind.as_u16(), nostr_bind::KIND); + assert_eq!(event.content, nostr_bind::CONTENT); + assert_eq!(event.pubkey, keys.public_key()); + assert!(event.verify_id()); + assert!(event.verify_signature()); + assert!(nostr::Event::from_json(event.as_json()).is_ok()); + + let tags = tag_values(&event); + assert!(tags.contains(&vec![ + "challenge_id".into(), + "550e8400-e29b-41d4-a716-446655440000".into(), + ])); + assert!(tags.contains(&vec![ + "nonce".into(), + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567".into(), + ])); + assert!(tags.contains(&vec!["verification_code".into(), "123456".into(),])); + assert!(tags.contains(&vec!["audience".into(), "buzz:nostr-identity".into()])); + assert!(tags.contains(&vec!["action".into(), "bind_nostr_identity".into(),])); + assert!(tags.contains(&vec!["protocol".into(), "buzz-nostr-identity".into(),])); + assert!(tags.contains(&vec!["version".into(), "1".into(),])); + assert!(tags.contains(&vec!["origin".into(), "https://example.com".into(),])); + assert!(tags.contains(&vec!["expires_at".into(), "2999-01-01T00:00:00Z".into(),])); + } + + #[test] + fn build_nostr_identity_binding_event_rejects_malformed_verification_code() { + let keys = Keys::generate(); + let error = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "12345a", + "https://example.com", + "2999-01-01T00:00:00Z", + ) + .unwrap_err(); + + assert_eq!(error, "verification_code must be exactly 6 digits"); + } + + #[test] + fn build_nostr_identity_binding_event_rejects_expired_link() { + let keys = Keys::generate(); + let error = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "123456", + "https://example.com", + "2000-01-01T00:00:00Z", + ) + .unwrap_err(); + + assert_eq!(error, "expires_at is expired"); + } +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 2940cfeec3..0833b777e5 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -1,6 +1,9 @@ +use serde::Serialize; use tauri::Emitter; use url::Url; +use crate::nostr_bind; + /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation @@ -33,6 +36,67 @@ fn parse_message_deep_link(url: &Url) -> Option { })) } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct NostrBindDeepLinkPayload { + challenge_id: String, + nonce: String, + verification_code: String, + audience: String, + action: String, + protocol: String, + version: String, + origin: String, + expires_at: String, + return_mode: String, +} + +fn non_empty_param(url: &Url, name: &str) -> Result { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("missing {name}")) +} + +fn parse_nostr_bind_deep_link(url: &Url) -> Result { + let challenge_id = non_empty_param(url, "challenge_id")?; + let nonce = non_empty_param(url, "nonce")?; + let verification_code = non_empty_param(url, "verification_code")?; + let audience = non_empty_param(url, "audience")?; + let action = non_empty_param(url, "action")?; + let protocol = non_empty_param(url, "protocol")?; + let version = non_empty_param(url, "version")?; + let origin = non_empty_param(url, "origin")?; + let expires_at = non_empty_param(url, "expires_at")?; + let return_mode = non_empty_param(url, "return")?; + + nostr_bind::validate_challenge_id(&challenge_id)?; + nostr_bind::validate_nonce(&nonce)?; + nostr_bind::validate_verification_code(&verification_code)?; + nostr_bind::validate_protocol_fields(&audience, &action, &protocol, &version)?; + nostr_bind::validate_origin(&origin)?; + // Expired links still reach the consent surface so the user gets an explicit + // failure instead of a silent stderr-only rejection from a launched app. + nostr_bind::validate_expires_at_format(&expires_at)?; + if return_mode != nostr_bind::RETURN_MODE { + return Err("unsupported return mode".into()); + } + + Ok(NostrBindDeepLinkPayload { + challenge_id, + nonce, + verification_code, + audience, + action, + protocol, + version, + origin, + expires_at, + return_mode, + }) +} + /// Handle an incoming `buzz://` deep link URL. /// /// Currently supports: @@ -93,6 +157,14 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { }; let _ = app.emit("deep-link-message", payload); } + Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { + Ok(payload) => { + let _ = app.emit("deep-link-nostr-bind", payload); + } + Err(error) => { + eprintln!("buzz-desktop: rejecting nostr-bind deep link: {error}: {url_str}"); + } + }, Some(action) => { eprintln!("buzz-desktop: unknown deep link action: {action}"); } @@ -106,7 +178,14 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { mod tests { use url::Url; - use super::parse_message_deep_link; + use super::{parse_message_deep_link, parse_nostr_bind_deep_link}; + + fn valid_nostr_bind_url() -> Url { + Url::parse( + "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", + ) + .unwrap() + } #[test] fn parse_message_deep_link_extracts_required_params() { @@ -157,4 +236,98 @@ mod tests { let payload = parse_message_deep_link(&url).expect("required params present"); assert!(payload["threadRootId"].is_null()); } + + #[test] + fn parse_nostr_bind_deep_link_accepts_valid_url() { + let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); + assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); + assert_eq!(payload.verification_code, "123456"); + assert_eq!(payload.audience, "buzz:nostr-identity"); + assert_eq!(payload.action, "bind_nostr_identity"); + assert_eq!(payload.protocol, "buzz-nostr-identity"); + assert_eq!(payload.version, "1"); + assert_eq!(payload.origin, "https://example.com"); + assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); + assert_eq!(payload.return_mode, "clipboard"); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { + let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_empty_nonce() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_short_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_long_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_wrong_action() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_wrong_audience() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_non_https_origin() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_origin_with_path() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); + } + + #[test] + fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); + } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 341c99d7b1..bf0424037b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod migration; #[cfg(test)] mod model_tests; mod models; +mod nostr_bind; pub mod nostr_convert; mod prevent_sleep; mod ptt_shortcut; @@ -464,6 +465,7 @@ pub fn run() { install_acp_runtime, discover_managed_agent_prereqs, sign_event, + sign_nostr_identity_binding, decrypt_observer_event, build_observer_control_event, create_auth_event, diff --git a/desktop/src-tauri/src/nostr_bind.rs b/desktop/src-tauri/src/nostr_bind.rs new file mode 100644 index 0000000000..69926466cc --- /dev/null +++ b/desktop/src-tauri/src/nostr_bind.rs @@ -0,0 +1,107 @@ +use chrono::{DateTime, Utc}; +use url::Url; + +pub(crate) const AUDIENCE: &str = "buzz:nostr-identity"; +pub(crate) const ACTION: &str = "bind_nostr_identity"; +pub(crate) const CONTENT: &str = ""; +pub(crate) const KIND: u16 = buzz_core_pkg::kind::KIND_NOSTR_IDENTITY_BINDING as u16; +pub(crate) const PROTOCOL: &str = "buzz-nostr-identity"; +pub(crate) const RETURN_MODE: &str = "clipboard"; +pub(crate) const VERSION: &str = "1"; + +const NONCE_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; +const VERIFICATION_CODE_LENGTH: usize = 6; + +pub(crate) fn validate_challenge_id(challenge_id: &str) -> Result<(), String> { + if challenge_id.is_empty() { + return Err("challenge_id is required".into()); + } + uuid::Uuid::parse_str(challenge_id).map_err(|_| "invalid challenge_id".to_string())?; + Ok(()) +} + +pub(crate) fn validate_nonce(nonce: &str) -> Result<(), String> { + if nonce.is_empty() { + return Err("nonce is required".into()); + } + if nonce.len() != 43 || !nonce.chars().all(|ch| NONCE_CHARS.contains(ch)) { + return Err("invalid nonce".into()); + } + Ok(()) +} + +pub(crate) fn validate_verification_code(verification_code: &str) -> Result<(), String> { + if verification_code.len() != VERIFICATION_CODE_LENGTH + || !verification_code.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("verification_code must be exactly 6 digits".into()); + } + Ok(()) +} + +pub(crate) fn validate_protocol_fields( + audience: &str, + action: &str, + protocol: &str, + version: &str, +) -> Result<(), String> { + if audience != AUDIENCE { + return Err("unsupported audience".into()); + } + if action != ACTION { + return Err("unsupported action".into()); + } + if protocol != PROTOCOL { + return Err("unsupported protocol".into()); + } + if version != VERSION { + return Err("unsupported version".into()); + } + Ok(()) +} + +pub(crate) fn validate_origin(origin: &str) -> Result<(), String> { + let parsed = Url::parse(origin).map_err(|error| format!("invalid origin: {error}"))?; + if parsed.scheme() != "https" { + return Err("origin must use https".into()); + } + if parsed.host_str().is_none() { + return Err("origin missing host".into()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("origin must not include credentials".into()); + } + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err("origin must not include path, query, or fragment".into()); + } + Ok(()) +} + +pub(crate) fn validate_expires_at_format(expires_at: &str) -> Result, String> { + DateTime::parse_from_rfc3339(expires_at) + .map_err(|error| format!("invalid expires_at: {error}")) + .map(|parsed| parsed.with_timezone(&Utc)) +} + +pub(crate) fn validate_expires_at(expires_at: &str) -> Result<(), String> { + let parsed = validate_expires_at_format(expires_at)?; + if parsed <= Utc::now() { + return Err("expires_at is expired".into()); + } + Ok(()) +} + +pub(crate) fn validate_signing_request( + challenge_id: &str, + nonce: &str, + verification_code: &str, + origin: &str, + expires_at: &str, +) -> Result<(), String> { + validate_challenge_id(challenge_id)?; + validate_nonce(nonce)?; + validate_verification_code(verification_code)?; + validate_origin(origin)?; + validate_expires_at(expires_at)?; + Ok(()) +} diff --git a/desktop/src/features/profile/lib/nostrIdentityBinding.ts b/desktop/src/features/profile/lib/nostrIdentityBinding.ts new file mode 100644 index 0000000000..352580b1ff --- /dev/null +++ b/desktop/src/features/profile/lib/nostrIdentityBinding.ts @@ -0,0 +1,15 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export type NostrIdentityBindingInput = { + challengeId: string; + nonce: string; + verificationCode: string; + origin: string; + expiresAt: string; +}; + +export function signNostrIdentityBinding( + input: NostrIdentityBindingInput, +): Promise { + return invokeTauri("sign_nostr_identity_binding", input); +} diff --git a/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx new file mode 100644 index 0000000000..b2d3f8839f --- /dev/null +++ b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx @@ -0,0 +1,262 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { getIdentity } from "@/shared/api/tauri"; +import type { Identity } from "@/shared/api/types"; +import type { NostrBindDeepLinkPayload } from "@/shared/deep-link"; +import { listenForNostrBindDeepLinks } from "@/shared/deep-link"; +import { signNostrIdentityBinding } from "@/features/profile/lib/nostrIdentityBinding"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Textarea } from "@/shared/ui/textarea"; + +const COPY_SUCCESS_MESSAGE = + "Signed response copied. Paste it back into the requesting app."; +const EXPIRED_LINK_MESSAGE = + "This binding link has expired. Request a new one from the requesting app."; + +function formatExpiry(expiresAt: string): string { + const date = new Date(expiresAt); + if (Number.isNaN(date.getTime())) { + return expiresAt; + } + return date.toLocaleString(); +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (error) { + console.warn("copy signed nostr binding response failed:", error); + return false; + } +} + +export function NostrBindConsentDialog() { + const [payload, setPayload] = React.useState( + null, + ); + const [identity, setIdentity] = React.useState(null); + const [isSigning, setIsSigning] = React.useState(false); + const [signedResponse, setSignedResponse] = React.useState( + null, + ); + const [copyFailed, setCopyFailed] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + const unlistenPromise = listenForNostrBindDeepLinks((nextPayload) => { + setPayload(nextPayload); + setIdentity(null); + setSignedResponse(null); + setCopyFailed(false); + setError(null); + getIdentity() + .then(setIdentity) + .catch((error) => { + console.warn("get_identity for nostr bind failed:", error); + setIdentity(null); + setError("Could not load the current Buzz identity."); + }); + }); + + return () => { + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, []); + + const isExpired = React.useMemo(() => { + if (!payload) { + return false; + } + const expiry = new Date(payload.expiresAt).getTime(); + return Number.isNaN(expiry) || expiry <= Date.now(); + }, [payload]); + + const resetDialog = React.useCallback(() => { + setPayload(null); + setSignedResponse(null); + setCopyFailed(false); + setError(null); + setIdentity(null); + setIsSigning(false); + }, []); + + const handleOpenChange = React.useCallback( + (open: boolean) => { + if (!open) { + resetDialog(); + } + }, + [resetDialog], + ); + + const handleSign = React.useCallback(async () => { + if (!payload) { + return; + } + if (isExpired) { + setError(EXPIRED_LINK_MESSAGE); + return; + } + + setIsSigning(true); + setError(null); + setCopyFailed(false); + try { + const signed = await signNostrIdentityBinding({ + challengeId: payload.challengeId, + nonce: payload.nonce, + verificationCode: payload.verificationCode, + origin: payload.origin, + expiresAt: payload.expiresAt, + }); + setSignedResponse(signed); + const copied = await copyToClipboard(signed); + setCopyFailed(!copied); + if (copied) { + toast.success(COPY_SUCCESS_MESSAGE); + } else { + toast.warning("Signed response ready. Copy it manually below."); + } + } catch (error) { + setError(formatError(error) || "Failed to sign binding response."); + } finally { + setIsSigning(false); + } + }, [isExpired, payload]); + + const handleCopyAgain = React.useCallback(async () => { + if (!signedResponse) { + return; + } + const copied = await copyToClipboard(signedResponse); + setCopyFailed(!copied); + if (copied) { + toast.success(COPY_SUCCESS_MESSAGE); + } + }, [signedResponse]); + + return ( + + + + Bind Buzz identity? + + Buzz will sign a one-time proof. Your private key is not shared. + + + + {payload ? ( +
+
+

+ Verification code +

+

+ {payload.verificationCode} +

+

+ Only sign if this code matches the code shown by the requesting + website. +

+
+ +
+
+
Requesting origin
+
+ {payload.origin} +
+
+
+
Buzz identity
+
+ {identity + ? `${identity.displayName} (${truncatePubkey(identity.pubkey)})` + : "Loading…"} +
+
+
+
Expires
+
+ {formatExpiry(payload.expiresAt)} +
+
+
+ + {isExpired ? ( +

+ {EXPIRED_LINK_MESSAGE} +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {signedResponse ? ( +
+

+ Signed response {copyFailed ? "ready" : "copied"}. Paste it + back into the requesting app. +

+