From 9b67c0a44301faefcfe7498e7be0695ba20c8f8a Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Wed, 8 Jul 2026 09:57:43 -0700 Subject: [PATCH 1/2] feat: add buzz nostr identity binding deeplink add a buzz://nostr-bind entrypoint that validates a scoped one-time challenge, shows a consent dialog, signs the response with the selected nostr identity, and copies the signed event json for the caller. use a buzz-owned custom ephemeral event kind (24243) instead of nip-98. nip-98 is tied to http request authorization and requires u/method semantics, while this flow is an app-to-app identity binding proof with challenge_id, nonce, audience, action, protocol, origin, and expires_at tags. keep the ipc boundary narrow: tauri parses and validates deeplink inputs, frontend only handles the typed payload, and signing stays behind a profile feature helper. restrict v1 to https origins, clipboard return mode, and the buzz-nostr-identity protocol so unsupported callbacks or protocol variants fail before signing. --- crates/buzz-core/src/kind.rs | 2 + desktop/src-tauri/src/commands/identity.rs | 123 +++++++++ desktop/src-tauri/src/deep_link.rs | 146 ++++++++++- desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/nostr_bind.rs | 95 +++++++ .../profile/lib/nostrIdentityBinding.ts | 14 + .../profile/ui/NostrBindConsentDialog.tsx | 248 ++++++++++++++++++ desktop/src/main.tsx | 2 + desktop/src/shared/deep-link.ts | 20 ++ 9 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/nostr_bind.rs create mode 100644 desktop/src/features/profile/lib/nostrIdentityBinding.ts create mode 100644 desktop/src/features/profile/ui/NostrBindConsentDialog.tsx 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..9d171c8a7b 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,62 @@ 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, + origin: &str, + expires_at: &str, +) -> Result { + nostr_bind::validate_signing_request(challenge_id, nonce, origin, expires_at)?; + + let tags = vec![ + nostr_bind_tag("challenge_id", challenge_id)?, + nostr_bind_tag("nonce", nonce)?, + 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, + origin: String, + expires_at: String, + state: State<'_, AppState>, +) -> Result { + nostr_bind::validate_signing_request(&challenge_id, &nonce, &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, &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 +326,69 @@ 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", + "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!["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_expired_link() { + let keys = Keys::generate(); + let error = build_nostr_identity_binding_event( + &keys, + "550e8400-e29b-41d4-a716-446655440000", + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567", + "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..f3209de385 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,63 @@ 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, + 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 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_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, + audience, + action, + protocol, + version, + origin, + expires_at, + return_mode, + }) +} + /// Handle an incoming `buzz://` deep link URL. /// /// Currently supports: @@ -93,6 +153,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 +174,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&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 +232,73 @@ 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.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&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=&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&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&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&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&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&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&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&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..bb8cb53951 --- /dev/null +++ b/desktop/src-tauri/src/nostr_bind.rs @@ -0,0 +1,95 @@ +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_-"; + +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_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, + origin: &str, + expires_at: &str, +) -> Result<(), String> { + validate_challenge_id(challenge_id)?; + validate_nonce(nonce)?; + 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..379be2b539 --- /dev/null +++ b/desktop/src/features/profile/lib/nostrIdentityBinding.ts @@ -0,0 +1,14 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export type NostrIdentityBindingInput = { + challengeId: string; + nonce: 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..56642bc999 --- /dev/null +++ b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx @@ -0,0 +1,248 @@ +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, + 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 ? ( +
+
+
+
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. +

+