Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
163 changes: 163 additions & 0 deletions desktop/src-tauri/src/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};

Expand Down Expand Up @@ -204,6 +205,83 @@ pub fn import_identity(
})
}

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,
Expand Down Expand Up @@ -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<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");
}
}
175 changes: 174 additions & 1 deletion desktop/src-tauri/src/deep_link.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}");
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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");
}
}
Loading
Loading