feat: add deeplink nostr identity binding flow (#1648)

Signed-off-by: npub1703xjpnw9z6ngtz632j4c7x82ve3mx4wg3wqgegjktllz8syepes0hu37e <f3e269066e28b5342c5a8aa55c78c753331d9aae445c046512b2fff11e04c873@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1703xjpnw9z6ngtz632j4c7x82ve3mx4wg3wqgegjktllz8syepes0hu37e <f3e269066e28b5342c5a8aa55c78c753331d9aae445c046512b2fff11e04c873@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Kalvin C
2026-07-08 14:48:11 -07:00
committed by GitHub
co-authored by npub1703xjpnw9z6ngtz632j4c7x82ve3mx4wg3wqgegjktllz8syepes0hu37e
parent 2b32469340
commit cecd031428
9 changed files with 748 additions and 1 deletions
+2
View File
@@ -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;
+163
View File
@@ -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},
};
@@ -209,6 +210,83 @@ pub async fn import_identity(
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
fn nostr_bind_tag(name: &str, value: &str) -> Result<Tag, String> {
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<Event, String> {
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<String, String> {
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,
@@ -274,3 +352,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<Vec<String>> {
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");
}
}
+174 -1
View File
@@ -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<serde_json::Value> {
}))
}
#[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<String, String> {
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<NostrBindDeepLinkPayload, String> {
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");
}
}
+2
View File
@@ -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;
@@ -469,6 +470,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,
+107
View File
@@ -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<DateTime<Utc>, 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(())
}
@@ -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<string> {
return invokeTauri<string>("sign_nostr_identity_binding", input);
}
@@ -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<boolean> {
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<NostrBindDeepLinkPayload | null>(
null,
);
const [identity, setIdentity] = React.useState<Identity | null>(null);
const [isSigning, setIsSigning] = React.useState(false);
const [signedResponse, setSignedResponse] = React.useState<string | null>(
null,
);
const [copyFailed, setCopyFailed] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 (
<Dialog onOpenChange={handleOpenChange} open={payload !== null}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Bind Buzz identity?</DialogTitle>
<DialogDescription>
Buzz will sign a one-time proof. Your private key is not shared.
</DialogDescription>
</DialogHeader>
{payload ? (
<div className="space-y-4 text-sm">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4 text-center">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Verification code
</p>
<p className="mt-2 font-mono text-4xl font-semibold tracking-[0.35em] text-foreground">
{payload.verificationCode}
</p>
<p className="mt-3 text-muted-foreground">
Only sign if this code matches the code shown by the requesting
website.
</p>
</div>
<dl className="space-y-2 rounded-lg border border-border/60 bg-muted/25 p-3">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Requesting origin</dt>
<dd className="break-all text-right font-medium">
{payload.origin}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Buzz identity</dt>
<dd className="break-all text-right font-medium">
{identity
? `${identity.displayName} (${truncatePubkey(identity.pubkey)})`
: "Loading…"}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Expires</dt>
<dd className="text-right font-medium">
{formatExpiry(payload.expiresAt)}
</dd>
</div>
</dl>
{isExpired ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
{EXPIRED_LINK_MESSAGE}
</p>
) : null}
{error ? (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
{error}
</p>
) : null}
{signedResponse ? (
<div className="space-y-2">
<p className="font-medium text-foreground">
Signed response {copyFailed ? "ready" : "copied"}. Paste it
back into the requesting app.
</p>
<Textarea
className="max-h-48 min-h-32 font-mono text-xs"
readOnly
value={signedResponse}
/>
</div>
) : null}
</div>
) : null}
<DialogFooter>
<Button
disabled={isSigning}
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
{signedResponse ? (
<Button
disabled={isSigning}
onClick={handleCopyAgain}
type="button"
>
Copy response
</Button>
) : (
<Button
disabled={isSigning || isExpired || identity === null}
onClick={handleSign}
type="button"
>
{isSigning ? "Signing…" : "Sign and copy response"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+2
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "@/app/App";
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
import "@fontsource-variable/inter/wght.css";
import "@/shared/styles/globals.css";
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
@@ -57,6 +58,7 @@ function renderApp() {
<PoofBurstProvider>
<UpdaterProvider>
<App />
<NostrBindConsentDialog />
</UpdaterProvider>
<Toaster />
</PoofBurstProvider>
+21
View File
@@ -23,6 +23,19 @@ export type MessageDeepLinkPayload = {
threadRootId: string | null;
};
export type NostrBindDeepLinkPayload = {
challengeId: string;
nonce: string;
verificationCode: string;
audience: "buzz:nostr-identity";
action: "bind_nostr_identity";
protocol: "buzz-nostr-identity";
version: "1";
origin: string;
expiresAt: string;
returnMode: "clipboard";
};
/**
* Register listeners for deep-link events emitted by the Rust backend.
*
@@ -64,3 +77,11 @@ export function listenForMessageDeepLinks(
onOpen(event.payload);
});
}
export function listenForNostrBindDeepLinks(
onOpen: (payload: NostrBindDeepLinkPayload) => void,
): Promise<UnlistenFn> {
return listen<NostrBindDeepLinkPayload>("deep-link-nostr-bind", (event) => {
onOpen(event.payload);
});
}