mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1703xjpnw9z6ngtz632j4c7x82ve3mx4wg3wqgegjktllz8syepes0hu37e
parent
2b32469340
commit
cecd031428
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user