mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat: NIP-AB device pairing — Phase 2 (desktop + mobile UI) (#343)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,9 @@ const overrides = new Map([
|
||||
["src-tauri/src/huddle/preprocessing.rs", 670], // TTS text preprocessing pipeline + unified split_sentences + int_to_words 0-999999 + URL trailing punctuation preservation + 23 unit tests
|
||||
["src-tauri/src/huddle/relay_api.rs", 510], // audio relay recv task + per-peer frame counting for remote human TTS interrupt
|
||||
["src-tauri/src/huddle/tts.rs", 1030], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fades + 18 unit tests for remote interrupt mechanism
|
||||
["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers
|
||||
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
|
||||
["src/shared/api/tauri.ts", 1110], // +14 lines for 3 NIP-AB pairing command wrappers
|
||||
]);
|
||||
|
||||
async function walkFiles(directory) {
|
||||
|
||||
15
desktop/src-tauri/Cargo.lock
generated
15
desktop/src-tauri/Cargo.lock
generated
@@ -5210,6 +5210,7 @@ dependencies = [
|
||||
"infer",
|
||||
"libc",
|
||||
"ndarray",
|
||||
"nostr 0.36.0",
|
||||
"nostr 0.37.0",
|
||||
"opus",
|
||||
"ort",
|
||||
@@ -5242,6 +5243,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
@@ -5258,6 +5260,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -6205,9 +6208,21 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
|
||||
@@ -35,7 +35,7 @@ tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
infer = "0.19"
|
||||
hex = "0.4"
|
||||
tokio = { version = "1", features = ["fs", "sync", "rt"] }
|
||||
tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time"] }
|
||||
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
futures-util = "0.3"
|
||||
@@ -43,6 +43,8 @@ opus = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
nostr = "0.37"
|
||||
nostr-compat = { package = "nostr", version = "0.36" }
|
||||
zeroize = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||
url = "2"
|
||||
sprout-core = { path = "../../crates/sprout-core" }
|
||||
|
||||
@@ -9,11 +9,12 @@ mod export_util;
|
||||
mod identity;
|
||||
mod media;
|
||||
mod messages;
|
||||
pub mod pairing;
|
||||
mod personas;
|
||||
mod profile;
|
||||
mod social;
|
||||
mod teams;
|
||||
mod tokens;
|
||||
pub mod tokens;
|
||||
mod workflows;
|
||||
|
||||
pub use agent_discovery::*;
|
||||
@@ -26,6 +27,7 @@ pub use dms::*;
|
||||
pub use identity::*;
|
||||
pub use media::*;
|
||||
pub use messages::*;
|
||||
pub use pairing::*;
|
||||
pub use personas::*;
|
||||
pub use profile::*;
|
||||
pub use social::*;
|
||||
|
||||
524
desktop/src-tauri/src/commands/pairing.rs
Normal file
524
desktop/src-tauri/src/commands/pairing.rs
Normal file
@@ -0,0 +1,524 @@
|
||||
//! NIP-AB device pairing — Tauri commands for the source (desktop) side.
|
||||
//!
|
||||
//! Architecture: a background tokio task owns the WebSocket connection.
|
||||
//! Tauri commands communicate with it via an mpsc channel (JSON strings).
|
||||
//! State changes are pushed to the React frontend via Tauri events.
|
||||
//!
|
||||
//! sprout-core depends on nostr 0.36 while the desktop uses nostr 0.37.
|
||||
//! We import `nostr_compat` (aliased to nostr 0.36) for types that cross
|
||||
//! the sprout-core boundary, and serialize events to JSON strings for the
|
||||
//! mpsc channel to avoid mixing the two versions.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nostr::ToBech32;
|
||||
use serde::Serialize;
|
||||
use sprout_core::kind::KIND_PAIRING;
|
||||
use sprout_core::pairing::qr::encode_qr;
|
||||
use sprout_core::pairing::session::PairingSession;
|
||||
use sprout_core::pairing::types::{AbortReason, PayloadType};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::relay::{relay_api_base_url, relay_ws_url};
|
||||
|
||||
use super::tokens::mint_token_internal;
|
||||
|
||||
// ── Tauri event payloads ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct PairingSasPayload {
|
||||
sas: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct PairingAbortedPayload {
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct PairingErrorPayload {
|
||||
message: String,
|
||||
}
|
||||
|
||||
// ── Managed state ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Managed Tauri state for an active pairing session.
|
||||
pub struct PairingHandle {
|
||||
session: Arc<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
cancel: std::sync::Mutex<Option<CancellationToken>>,
|
||||
/// Send JSON-serialized events to the background WS task for relay publication.
|
||||
outbound_tx: std::sync::Mutex<Option<mpsc::Sender<String>>>,
|
||||
/// Pre-built payload string (contains nsec) to send after SAS confirmation.
|
||||
/// Wrapped in Zeroizing so the nsec is cleared from memory on drop.
|
||||
payload: std::sync::Mutex<Option<Zeroizing<String>>>,
|
||||
}
|
||||
|
||||
impl PairingHandle {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
session: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
cancel: std::sync::Mutex::new(None),
|
||||
outbound_tx: std::sync::Mutex::new(None),
|
||||
payload: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
*self.cancel.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*self.outbound_tx.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*self.payload.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
// Note: session is in an Arc<tokio::sync::Mutex> and is cleared
|
||||
// by the background task on exit. For immediate cleanup, callers
|
||||
// should also lock and clear the session explicitly.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mobile scopes ───────────────────────────────────────────────────────────
|
||||
|
||||
const MOBILE_SCOPES: &[&str] = &[
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"channels:write",
|
||||
"users:read",
|
||||
"files:read",
|
||||
];
|
||||
const EXPIRES_IN_DAYS: u32 = 90;
|
||||
|
||||
// ── Commands ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Start a NIP-AB pairing session as the source device.
|
||||
///
|
||||
/// Mints a token, creates a `PairingSession`, connects to the relay,
|
||||
/// and returns the `nostrpair://` QR URI for the frontend to display.
|
||||
#[tauri::command]
|
||||
pub async fn start_pairing(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
pairing: State<'_, PairingHandle>,
|
||||
) -> Result<String, String> {
|
||||
// Cancel any existing session.
|
||||
if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() {
|
||||
token.cancel();
|
||||
}
|
||||
pairing.clear();
|
||||
|
||||
// 1. Mint a mobile token.
|
||||
let scopes: Vec<String> = MOBILE_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let token_name = format!("mobile-pairing-{}", chrono::Utc::now().timestamp());
|
||||
let mint_result =
|
||||
mint_token_internal(&state, &token_name, &scopes, None, Some(EXPIRES_IN_DAYS)).await?;
|
||||
|
||||
// 2. Read user identity.
|
||||
let (nsec, pubkey_hex) = {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
let nsec = keys
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|e| format!("encode nsec: {e}"))?;
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
(nsec, pubkey)
|
||||
};
|
||||
|
||||
// 3. Get relay URLs.
|
||||
let ws_url = relay_ws_url();
|
||||
let http_url = relay_api_base_url();
|
||||
|
||||
// 4. Create the pairing session.
|
||||
let (session, qr_payload) = PairingSession::new_source(ws_url.clone());
|
||||
let qr_uri = encode_qr(&qr_payload);
|
||||
|
||||
// 5. Build the custom payload to send after SAS confirmation.
|
||||
let payload_json = serde_json::json!({
|
||||
"relayUrl": http_url,
|
||||
"token": mint_result.token,
|
||||
"pubkey": pubkey_hex,
|
||||
"nsec": nsec,
|
||||
});
|
||||
|
||||
// 6. Store session + payload.
|
||||
{
|
||||
let mut s = pairing.session.lock().await;
|
||||
*s = Some(session);
|
||||
}
|
||||
*pairing.payload.lock().map_err(|e| e.to_string())? =
|
||||
Some(Zeroizing::new(payload_json.to_string()));
|
||||
|
||||
// 7. Create channel + cancellation token.
|
||||
let (outbound_tx, outbound_rx) = mpsc::channel::<String>(16);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
*pairing.outbound_tx.lock().map_err(|e| e.to_string())? = Some(outbound_tx);
|
||||
*pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone());
|
||||
|
||||
// 8. Spawn background WS task.
|
||||
let session_arc = Arc::clone(&pairing.session);
|
||||
tauri::async_runtime::spawn(pairing_ws_task(
|
||||
ws_url,
|
||||
session_arc,
|
||||
cancel,
|
||||
outbound_rx,
|
||||
app,
|
||||
));
|
||||
|
||||
Ok(qr_uri)
|
||||
}
|
||||
|
||||
/// User confirmed the SAS codes match. Sends sas-confirm + payload.
|
||||
#[tauri::command]
|
||||
pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<(), String> {
|
||||
let tx = pairing
|
||||
.outbound_tx
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
.ok_or("no active pairing session")?;
|
||||
|
||||
// 1. Confirm SAS → get sas-confirm event, serialize to JSON.
|
||||
let sas_confirm_json = {
|
||||
let mut guard = pairing.session.lock().await;
|
||||
let session = guard.as_mut().ok_or("no active pairing session")?;
|
||||
let event = session.confirm_sas().map_err(|e| e.to_string())?;
|
||||
event_to_relay_json(&event)
|
||||
};
|
||||
|
||||
tx.send(sas_confirm_json)
|
||||
.await
|
||||
.map_err(|_| "failed to send sas-confirm")?;
|
||||
|
||||
// 2. Send payload (contains nsec — Zeroizing ensures cleanup).
|
||||
let payload = pairing
|
||||
.payload
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.take()
|
||||
.ok_or("no payload prepared")?;
|
||||
|
||||
let payload_json = {
|
||||
let mut guard = pairing.session.lock().await;
|
||||
let session = guard.as_mut().ok_or("no active pairing session")?;
|
||||
let event = session
|
||||
.send_payload(PayloadType::Custom, payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
event_to_relay_json(&event)
|
||||
};
|
||||
|
||||
tx.send(payload_json)
|
||||
.await
|
||||
.map_err(|_| "failed to send payload")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel the active pairing session.
|
||||
#[tauri::command]
|
||||
pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), String> {
|
||||
// Try to send an abort event if we have a session and a channel.
|
||||
let abort_json = {
|
||||
let mut guard = pairing.session.lock().await;
|
||||
if let Some(session) = guard.as_mut() {
|
||||
session
|
||||
.abort(AbortReason::UserDenied)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|e| event_to_relay_json(&e))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(json) = abort_json {
|
||||
let tx = pairing
|
||||
.outbound_tx
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone();
|
||||
if let Some(tx) = tx {
|
||||
let _ = tx.send(json).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel background task.
|
||||
if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() {
|
||||
token.cancel();
|
||||
}
|
||||
pairing.clear();
|
||||
|
||||
// Clear session.
|
||||
{
|
||||
let mut s = pairing.session.lock().await;
|
||||
*s = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Background WebSocket task ───────────────────────────────────────────────
|
||||
|
||||
async fn pairing_ws_task(
|
||||
relay_url: String,
|
||||
session: Arc<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
cancel: CancellationToken,
|
||||
mut outbound_rx: mpsc::Receiver<String>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
if let Err(e) =
|
||||
pairing_ws_task_inner(&relay_url, &session, &cancel, &mut outbound_rx, &app).await
|
||||
{
|
||||
let _ = app.emit("pairing-error", PairingErrorPayload { message: e });
|
||||
}
|
||||
// Clean up session on exit.
|
||||
let mut s = session.lock().await;
|
||||
*s = None;
|
||||
}
|
||||
|
||||
async fn pairing_ws_task_inner(
|
||||
relay_url: &str,
|
||||
session: &Arc<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
cancel: &CancellationToken,
|
||||
outbound_rx: &mut mpsc::Receiver<String>,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
// Connect to relay.
|
||||
let (ws, _) = connect_async(relay_url)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connection failed: {e}"))?;
|
||||
let (mut write, mut read) = ws.split();
|
||||
|
||||
// Handle NIP-42 auth if required.
|
||||
handle_nip42_auth(&mut read, &mut write, session, relay_url).await?;
|
||||
|
||||
// Subscribe for kind:24134 events tagged to our ephemeral pubkey.
|
||||
let our_pk = {
|
||||
let guard = session.lock().await;
|
||||
guard.as_ref().ok_or("session gone")?.pubkey().to_hex()
|
||||
};
|
||||
let sub_msg = serde_json::json!([
|
||||
"REQ", "pair",
|
||||
{ "kinds": [KIND_PAIRING], "#p": [our_pk] }
|
||||
]);
|
||||
write
|
||||
.send(Message::Text(sub_msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("subscribe failed: {e}"))?;
|
||||
|
||||
// Wait for EOSE to confirm subscription is registered.
|
||||
wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?;
|
||||
|
||||
// Event loop.
|
||||
let hard_timeout = tokio::time::sleep(Duration::from_secs(130));
|
||||
tokio::pin!(hard_timeout);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = &mut hard_timeout => {
|
||||
let _ = app.emit("pairing-error", PairingErrorPayload {
|
||||
message: "Session timed out".into(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
Some(json_msg) = outbound_rx.recv() => {
|
||||
// json_msg is already a ["EVENT", ...] JSON string.
|
||||
if let Err(e) = write.send(Message::Text(json_msg.into())).await {
|
||||
return Err(format!("publish failed: {e}"));
|
||||
}
|
||||
}
|
||||
msg = read.next() => {
|
||||
let Some(msg) = msg else {
|
||||
return Err("relay connection closed".into());
|
||||
};
|
||||
let msg = msg.map_err(|e| format!("WS read error: {e}"))?;
|
||||
let Message::Text(text) = msg else { continue };
|
||||
|
||||
// Parse relay EVENT message into sprout-core's nostr::Event.
|
||||
if let Some(event) = parse_relay_event(text.as_str(), "pair") {
|
||||
let mut guard = session.lock().await;
|
||||
let Some(s) = guard.as_mut() else { break };
|
||||
|
||||
// Check for abort from peer.
|
||||
match s.handle_abort(&event) {
|
||||
Ok(reason) => {
|
||||
let _ = app.emit("pairing-aborted", PairingAbortedPayload {
|
||||
reason: format!("{reason:?}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(_) => {} // Not an abort — continue.
|
||||
}
|
||||
|
||||
// Try handle_offer (source waits for this first).
|
||||
if let Ok(sas) = s.handle_offer(&event) {
|
||||
let _ = app.emit("pairing-sas-received", PairingSasPayload { sas });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try handle_complete (source waits for this after sending payload).
|
||||
match s.handle_complete(&event) {
|
||||
Ok(()) => {
|
||||
let _ = app.emit("pairing-complete", serde_json::json!({}));
|
||||
break;
|
||||
}
|
||||
Err(ref e) if format!("{e}").contains("success=false") => {
|
||||
let _ = app.emit("pairing-error", PairingErrorPayload {
|
||||
message: "Mobile device reported failure importing credentials".into(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(_) => {} // Wrong state or wrong message — discard per NIP-AB.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-42 auth helper ──────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_nip42_auth<R, W>(
|
||||
read: &mut R,
|
||||
write: &mut W,
|
||||
session: &Arc<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
relay_url: &str,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
R: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
W: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
{
|
||||
// Wait up to 3 seconds for an AUTH challenge. Timeout is normal.
|
||||
let auth_result = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
let msg = read
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| "relay closed during auth".to_string())?
|
||||
.map_err(|e| format!("WS error during auth: {e}"))?;
|
||||
if let Message::Text(text) = msg {
|
||||
if let Some(challenge) = parse_auth_challenge(text.as_str()) {
|
||||
return Ok(challenge);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let challenge: String = match auth_result {
|
||||
Ok(Ok(c)) => c,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()), // No AUTH challenge.
|
||||
};
|
||||
|
||||
// Sign auth event with the session's ephemeral keys.
|
||||
// Use sprout-core's nostr EventBuilder (0.36) via PairingSession::sign_event.
|
||||
let relay_url_parsed: url::Url = relay_url
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid relay URL: {e}"))?;
|
||||
let auth_json = {
|
||||
let guard = session.lock().await;
|
||||
let s = guard.as_ref().ok_or("session gone during auth")?;
|
||||
let auth_event = s
|
||||
.sign_event(nostr_compat::EventBuilder::auth(
|
||||
challenge,
|
||||
relay_url_parsed,
|
||||
))
|
||||
.map_err(|e| format!("sign auth event: {e}"))?;
|
||||
format!(
|
||||
"[\"AUTH\",{}]",
|
||||
nostr_compat::JsonUtil::as_json(&auth_event)
|
||||
)
|
||||
};
|
||||
|
||||
write
|
||||
.send(Message::Text(auth_json.into()))
|
||||
.await
|
||||
.map_err(|e| format!("send auth: {e}"))?;
|
||||
|
||||
// Wait for OK response (up to 5 seconds).
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let msg = read
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| "relay closed during auth OK".to_string())?
|
||||
.map_err(|e| format!("WS error during auth OK: {e}"))?;
|
||||
if let Message::Text(text) = msg {
|
||||
if text.contains("\"OK\"") || text.contains("[\"OK\"") {
|
||||
return Ok::<(), String>(());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Helper functions ────────────────────────────────────────────────────────
|
||||
|
||||
/// Serialize a nostr 0.36 Event to `["EVENT", <event>]` JSON string.
|
||||
fn event_to_relay_json(event: &nostr_compat::Event) -> String {
|
||||
format!("[\"EVENT\",{}]", nostr_compat::JsonUtil::as_json(event))
|
||||
}
|
||||
|
||||
/// Parse a relay EVENT message into a nostr 0.36 Event (sprout-core compatible).
|
||||
fn parse_relay_event(text: &str, sub_id: &str) -> Option<nostr_compat::Event> {
|
||||
let arr: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let arr = arr.as_array()?;
|
||||
if arr.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
if arr[0].as_str()? != "EVENT" {
|
||||
return None;
|
||||
}
|
||||
if arr[1].as_str()? != sub_id {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_value(arr[2].clone()).ok()
|
||||
}
|
||||
|
||||
fn parse_auth_challenge(text: &str) -> Option<String> {
|
||||
let arr: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let arr = arr.as_array()?;
|
||||
if arr.len() >= 2 && arr[0].as_str()? == "AUTH" {
|
||||
return arr[1].as_str().map(|s| s.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn wait_for_eose<S>(read: &mut S, sub_id: &str, dur: Duration) -> Result<(), String>
|
||||
where
|
||||
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
{
|
||||
tokio::time::timeout(dur, async {
|
||||
loop {
|
||||
let msg = read
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| "relay closed waiting for EOSE".to_string())?
|
||||
.map_err(|e| format!("WS error waiting for EOSE: {e}"))?;
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(arr) = serde_json::from_str::<serde_json::Value>(text.as_str()) {
|
||||
if let Some(arr) = arr.as_array() {
|
||||
if arr.len() >= 2
|
||||
&& arr[0].as_str() == Some("EOSE")
|
||||
&& arr[1].as_str() == Some(sub_id)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "timeout waiting for EOSE".to_string())?
|
||||
}
|
||||
@@ -17,28 +17,28 @@ pub async fn list_tokens(state: State<'_, AppState>) -> Result<ListTokensRespons
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mint_token(
|
||||
name: String,
|
||||
scopes: Vec<String>,
|
||||
channel_ids: Option<Vec<String>>,
|
||||
/// Internal token minting logic, callable from other modules (e.g. pairing).
|
||||
pub async fn mint_token_internal(
|
||||
state: &AppState,
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
channel_ids: Option<&[String]>,
|
||||
expires_in_days: Option<u32>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MintTokenResponse, String> {
|
||||
let body = MintTokenBody {
|
||||
name: &name,
|
||||
scopes: &scopes,
|
||||
channel_ids: channel_ids.as_deref(),
|
||||
name,
|
||||
scopes,
|
||||
channel_ids,
|
||||
expires_in_days,
|
||||
owner_pubkey: None, // User-minted tokens don't set agent owner
|
||||
owner_pubkey: None,
|
||||
};
|
||||
let request = if state.configured_api_token.is_some() {
|
||||
build_authed_request(&state.http_client, Method::POST, "/api/tokens", &state)?.json(&body)
|
||||
build_authed_request(&state.http_client, Method::POST, "/api/tokens", state)?.json(&body)
|
||||
} else {
|
||||
let url = format!("{}{}", relay_api_base_url(), "/api/tokens");
|
||||
let body_bytes =
|
||||
serde_json::to_vec(&body).map_err(|error| format!("serialize failed: {error}"))?;
|
||||
let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, &state)?;
|
||||
let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?;
|
||||
|
||||
state
|
||||
.http_client
|
||||
@@ -60,6 +60,24 @@ pub async fn mint_token(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mint_token(
|
||||
name: String,
|
||||
scopes: Vec<String>,
|
||||
channel_ids: Option<Vec<String>>,
|
||||
expires_in_days: Option<u32>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MintTokenResponse, String> {
|
||||
mint_token_internal(
|
||||
&state,
|
||||
&name,
|
||||
&scopes,
|
||||
channel_ids.as_deref(),
|
||||
expires_in_days,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn revoke_token(token_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let path = api_path(&["tokens", &token_id]);
|
||||
|
||||
@@ -468,6 +468,7 @@ pub fn run() {
|
||||
});
|
||||
})
|
||||
.manage(build_app_state())
|
||||
.manage(commands::pairing::PairingHandle::new())
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
let shutdown_started = Arc::clone(&restore_shutdown_started);
|
||||
@@ -647,6 +648,9 @@ pub fn run() {
|
||||
list_audio_output_devices,
|
||||
set_audio_output_device,
|
||||
get_audio_output_device,
|
||||
start_pairing,
|
||||
confirm_pairing_sas,
|
||||
cancel_pairing,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { Check, Copy, Loader2, Smartphone, TriangleAlert } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { useMintTokenMutation } from "@/features/tokens/hooks";
|
||||
import { getNsec, getRelayHttpUrl } from "@/shared/api/tauri";
|
||||
import type { TokenScope } from "@/shared/api/types";
|
||||
import {
|
||||
cancelPairing,
|
||||
confirmPairingSas,
|
||||
startPairing,
|
||||
} from "@/shared/api/tauri";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,84 +25,56 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
|
||||
const MOBILE_SCOPES: TokenScope[] = [
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"channels:write",
|
||||
"users:read",
|
||||
"files:read",
|
||||
];
|
||||
const EXPIRES_IN_DAYS = 90;
|
||||
|
||||
function toBase64Url(str: string): string {
|
||||
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
type PairingPayload = {
|
||||
relayUrl: string;
|
||||
token: string;
|
||||
pubkey: string;
|
||||
nsec: string;
|
||||
};
|
||||
type PairingStep =
|
||||
| "generating"
|
||||
| "qr"
|
||||
| "sas"
|
||||
| "transferring"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
function PairingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentPubkey,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentPubkey: string;
|
||||
}) {
|
||||
const mintMutation = useMintTokenMutation();
|
||||
const mintRef = useRef(mintMutation.mutateAsync);
|
||||
mintRef.current = mintMutation.mutateAsync;
|
||||
|
||||
const [pairingUri, setPairingUri] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<PairingStep>("generating");
|
||||
const [qrUri, setQrUri] = useState<string | null>(null);
|
||||
const [sasCode, setSasCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const stepRef = useRef(step);
|
||||
stepRef.current = step;
|
||||
|
||||
const generate = useCallback(async (pubkey: string) => {
|
||||
const [tokenResult, relayUrl, nsec] = await Promise.all([
|
||||
mintRef.current({
|
||||
name: `mobile-${Date.now()}`,
|
||||
scopes: [...MOBILE_SCOPES],
|
||||
expiresInDays: EXPIRES_IN_DAYS,
|
||||
}),
|
||||
getRelayHttpUrl(),
|
||||
getNsec(),
|
||||
]);
|
||||
|
||||
const payload: PairingPayload = {
|
||||
relayUrl,
|
||||
token: tokenResult.token,
|
||||
pubkey,
|
||||
nsec,
|
||||
};
|
||||
return `sprout://${toBase64Url(JSON.stringify(payload))}`;
|
||||
}, []);
|
||||
|
||||
// Start pairing when dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setPairingUri(null);
|
||||
setStep("generating");
|
||||
setQrUri(null);
|
||||
setSasCode(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
generate(currentPubkey).then(
|
||||
startPairing().then(
|
||||
(uri) => {
|
||||
if (!cancelled) setPairingUri(uri);
|
||||
if (!cancelled) {
|
||||
setQrUri(uri);
|
||||
setStep("qr");
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to generate pairing code",
|
||||
: "Failed to start pairing session",
|
||||
);
|
||||
setStep("error");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -99,17 +82,98 @@ function PairingDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, currentPubkey, generate]);
|
||||
}, [open]);
|
||||
|
||||
// Listen for Tauri events from the pairing backend.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let cancelled = false;
|
||||
const unlisteners: (() => void)[] = [];
|
||||
|
||||
listen<{ sas: string }>("pairing-sas-received", (event) => {
|
||||
if (!cancelled) {
|
||||
setSasCode(event.payload.sas);
|
||||
setStep("sas");
|
||||
}
|
||||
}).then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisteners.push(fn);
|
||||
});
|
||||
|
||||
listen("pairing-complete", () => {
|
||||
if (!cancelled) {
|
||||
setStep("done");
|
||||
}
|
||||
}).then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisteners.push(fn);
|
||||
});
|
||||
|
||||
listen<{ reason: string }>("pairing-aborted", (event) => {
|
||||
if (!cancelled) {
|
||||
setError(`Pairing aborted: ${event.payload.reason}`);
|
||||
setStep("error");
|
||||
}
|
||||
}).then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisteners.push(fn);
|
||||
});
|
||||
|
||||
listen<{ message: string }>("pairing-error", (event) => {
|
||||
if (!cancelled) {
|
||||
setError(event.payload.message);
|
||||
setStep("error");
|
||||
}
|
||||
}).then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisteners.push(fn);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const fn of unlisteners) fn();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Cancel pairing when dialog closes before completion.
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen && stepRef.current !== "done") {
|
||||
cancelPairing().catch(() => {});
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
async function handleConfirmSas() {
|
||||
setStep("transferring");
|
||||
try {
|
||||
await confirmPairingSas();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to send credentials",
|
||||
);
|
||||
setStep("error");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDenySas() {
|
||||
cancelPairing().catch(() => {});
|
||||
setError("SAS code mismatch — pairing cancelled for security.");
|
||||
setStep("error");
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!pairingUri) return;
|
||||
await navigator.clipboard.writeText(pairingUri);
|
||||
if (!qrUri) return;
|
||||
await navigator.clipboard.writeText(qrUri);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="max-w-md overflow-hidden p-0"
|
||||
data-testid="mobile-pairing-dialog"
|
||||
@@ -118,32 +182,35 @@ function PairingDialog({
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Pair Mobile Device</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan this QR code with the Sprout mobile app, or copy the pairing
|
||||
code for manual setup.
|
||||
{step === "sas"
|
||||
? "Verify the security code matches your mobile device."
|
||||
: step === "done"
|
||||
? "Your mobile device is now paired."
|
||||
: "Scan this QR code with the Sprout mobile app to securely pair."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{error ? (
|
||||
{step === "error" && error ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : !pairingUri ? (
|
||||
) : step === "generating" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generating pairing code…
|
||||
Preparing secure pairing session...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
) : step === "qr" && qrUri ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center rounded-lg border border-border/70 bg-white p-4">
|
||||
<QRCodeSVG
|
||||
data-testid="mobile-pairing-qr"
|
||||
level="M"
|
||||
size={240}
|
||||
value={pairingUri}
|
||||
value={qrUri}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +220,7 @@ function PairingDialog({
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-xs">
|
||||
{pairingUri}
|
||||
{qrUri}
|
||||
</code>
|
||||
<Button
|
||||
data-testid="copy-pairing-code"
|
||||
@@ -170,22 +237,81 @@ function PairingDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This token expires in {EXPIRES_IN_DAYS} days. You can revoke
|
||||
it from the Tokens settings at any time.
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Waiting for mobile device to scan...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : step === "sas" && sasCode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<ShieldCheck className="h-10 w-10 text-primary" />
|
||||
<p className="text-sm font-medium">
|
||||
Verify this code matches your mobile device
|
||||
</p>
|
||||
<div className="rounded-xl border-2 border-primary/30 bg-primary/5 px-8 py-4">
|
||||
<p
|
||||
className="font-mono text-4xl font-bold tracking-[0.3em]"
|
||||
data-testid="pairing-sas-code"
|
||||
>
|
||||
{sasCode.slice(0, 3)} {sasCode.slice(3)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
You are about to transfer your Sprout identity to another
|
||||
device. Only confirm if you initiated this pairing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
data-testid="deny-sas"
|
||||
onClick={handleDenySas}
|
||||
variant="outline"
|
||||
>
|
||||
<X className="mr-1.5 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
data-testid="confirm-sas"
|
||||
onClick={handleConfirmSas}
|
||||
>
|
||||
<Check className="mr-1.5 h-4 w-4" />
|
||||
Codes Match
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : step === "transferring" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sending identity to mobile device...
|
||||
</p>
|
||||
</div>
|
||||
) : step === "done" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Check className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">
|
||||
Mobile device paired successfully
|
||||
</p>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Your mobile app is now connected to this relay.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border/60 bg-background/95 px-6 py-4">
|
||||
<Button
|
||||
data-testid="mobile-pairing-done"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Done
|
||||
{step === "done" ? "Done" : "Close"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,8 +332,9 @@ export function MobilePairingCard({
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Mobile</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect the Sprout mobile app to this relay by scanning a QR code or
|
||||
pasting a pairing code.
|
||||
Connect the Sprout mobile app to this relay by scanning a QR code. The
|
||||
connection is secured with end-to-end encryption and a verification
|
||||
code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -216,7 +343,7 @@ export function MobilePairingCard({
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Pair Mobile Device</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generate a one-time pairing code for the mobile app
|
||||
Securely transfer your identity via NIP-AB protocol
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -230,11 +357,7 @@ export function MobilePairingCard({
|
||||
</div>
|
||||
|
||||
{currentPubkey && (
|
||||
<PairingDialog
|
||||
currentPubkey={currentPubkey}
|
||||
onOpenChange={setDialogOpen}
|
||||
open={dialogOpen}
|
||||
/>
|
||||
<PairingDialog onOpenChange={setDialogOpen} open={dialogOpen} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1091,3 +1091,17 @@ export async function probeBackendProvider(
|
||||
binaryPath,
|
||||
});
|
||||
}
|
||||
|
||||
// ── NIP-AB device pairing ───────────────────────────────────────────────────
|
||||
|
||||
export async function startPairing(): Promise<string> {
|
||||
return invokeTauri<string>("start_pairing");
|
||||
}
|
||||
|
||||
export async function confirmPairingSas(): Promise<void> {
|
||||
await invokeTauri("confirm_pairing_sas");
|
||||
}
|
||||
|
||||
export async function cancelPairing(): Promise<void> {
|
||||
await invokeTauri("cancel_pairing");
|
||||
}
|
||||
|
||||
@@ -9,12 +9,15 @@ import '../profile/user_profile.dart';
|
||||
/// what the desktop does via `submit_event`.
|
||||
class SendMessage {
|
||||
final SignedEventRelay _signedEventRelay;
|
||||
final RelayClient _relayClient;
|
||||
final Map<String, UserProfile> Function() _readUserCache;
|
||||
|
||||
SendMessage({
|
||||
required SignedEventRelay signedEventRelay,
|
||||
required RelayClient relayClient,
|
||||
required Map<String, UserProfile> Function() readUserCache,
|
||||
}) : _signedEventRelay = signedEventRelay,
|
||||
_relayClient = relayClient,
|
||||
_readUserCache = readUserCache;
|
||||
|
||||
/// Send a text message to a channel.
|
||||
@@ -30,8 +33,10 @@ class SendMessage {
|
||||
String? rootEventId,
|
||||
List<String>? mentionPubkeys,
|
||||
}) async {
|
||||
// Resolve @mentions in the message content to pubkeys.
|
||||
final resolvedMentions = mentionPubkeys ?? _resolveMentions(content);
|
||||
// Use explicitly passed pubkeys, or resolve @mentions against
|
||||
// channel members to avoid matching the wrong user.
|
||||
final resolvedMentions =
|
||||
mentionPubkeys ?? await _resolveMentions(content, channelId);
|
||||
final authorPubkey = _signedEventRelay.pubkey;
|
||||
|
||||
// Normalize mentions: lowercase, deduplicate, exclude self (matching
|
||||
@@ -44,7 +49,6 @@ class SendMessage {
|
||||
];
|
||||
|
||||
final tags = <List<String>>[
|
||||
if (authorPubkey != null) ['p', authorPubkey],
|
||||
['h', channelId],
|
||||
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
|
||||
for (final pk in normalizedMentions) ['p', pk],
|
||||
@@ -57,16 +61,36 @@ class SendMessage {
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse @mentions from message content and resolve to pubkeys using
|
||||
/// the user cache. Reads the cache at call time (not construction time)
|
||||
/// to ensure freshly loaded profiles are available.
|
||||
List<String> _resolveMentions(String content) {
|
||||
/// Resolve @mentions to pubkeys, scoped to channel members.
|
||||
///
|
||||
/// Fetches channel members from the relay and matches @names only
|
||||
/// against members of that channel. Falls back to the full user cache
|
||||
/// if the member fetch fails.
|
||||
Future<List<String>> _resolveMentions(
|
||||
String content,
|
||||
String channelId,
|
||||
) async {
|
||||
final mentionPattern = RegExp(r'@(\w+)');
|
||||
final matches = mentionPattern.allMatches(content);
|
||||
final pubkeys = <String>{};
|
||||
if (matches.isEmpty) return const [];
|
||||
|
||||
// Try to get channel member pubkeys for scoped resolution.
|
||||
Set<String>? memberPubkeys;
|
||||
try {
|
||||
final json =
|
||||
await _relayClient.get('/api/channels/$channelId/members')
|
||||
as Map<String, dynamic>;
|
||||
final members = json['members'] as List<dynamic>? ?? [];
|
||||
memberPubkeys = {
|
||||
for (final m in members)
|
||||
((m as Map<String, dynamic>)['pubkey'] as String).toLowerCase(),
|
||||
};
|
||||
} catch (_) {
|
||||
// Non-fatal — fall through to unscoped cache lookup.
|
||||
}
|
||||
|
||||
// Read the current cache state at send time.
|
||||
final cache = _readUserCache();
|
||||
final pubkeys = <String>{};
|
||||
|
||||
for (final match in matches) {
|
||||
final name = match.group(1)?.toLowerCase();
|
||||
@@ -76,12 +100,18 @@ class SendMessage {
|
||||
final displayName = profile.displayName?.toLowerCase();
|
||||
if (displayName == null) continue;
|
||||
|
||||
// Match against full display name or first word of it.
|
||||
// Match against full display name or first word.
|
||||
final firstName = displayName.split(RegExp(r'\s+')).first;
|
||||
if (displayName == name || firstName == name) {
|
||||
pubkeys.add(profile.pubkey);
|
||||
break;
|
||||
if (displayName != name && firstName != name) continue;
|
||||
|
||||
// If we have channel members, only match members of this channel.
|
||||
if (memberPubkeys != null &&
|
||||
!memberPubkeys.contains(profile.pubkey.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pubkeys.add(profile.pubkey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +145,7 @@ final sendMessageProvider = Provider<SendMessage>((ref) {
|
||||
client: ref.watch(relayClientProvider),
|
||||
nsec: config.nsec,
|
||||
),
|
||||
relayClient: ref.watch(relayClientProvider),
|
||||
readUserCache: () => ref.read(userCacheProvider),
|
||||
);
|
||||
});
|
||||
|
||||
164
mobile/lib/features/pairing/pairing_crypto.dart
Normal file
164
mobile/lib/features/pairing/pairing_crypto.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../../shared/crypto/ecdh.dart';
|
||||
import '../../shared/crypto/hkdf.dart';
|
||||
|
||||
/// Derive session ID from session secret.
|
||||
/// session_id = HKDF-SHA256(IKM=session_secret, salt="", info="nostr-pair-session-id", L=32)
|
||||
Uint8List deriveSessionId(Uint8List sessionSecret) {
|
||||
return hkdf(
|
||||
sessionSecret,
|
||||
Uint8List(0), // empty salt
|
||||
Uint8List.fromList('nostr-pair-session-id'.codeUnits),
|
||||
32,
|
||||
);
|
||||
}
|
||||
|
||||
/// Derive SAS code and SAS input from ECDH shared secret and session secret.
|
||||
/// sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32)
|
||||
/// sas_code = be_u32(sas_input[0..4]) % 1_000_000
|
||||
(int, Uint8List) deriveSas(Uint8List ecdhShared, Uint8List sessionSecret) {
|
||||
final sasInput = hkdf(
|
||||
ecdhShared,
|
||||
sessionSecret,
|
||||
Uint8List.fromList('nostr-pair-sas-v1'.codeUnits),
|
||||
32,
|
||||
);
|
||||
|
||||
// be_u32 from first 4 bytes.
|
||||
final code =
|
||||
((sasInput[0] << 24) |
|
||||
(sasInput[1] << 16) |
|
||||
(sasInput[2] << 8) |
|
||||
sasInput[3]) %
|
||||
1000000;
|
||||
|
||||
return (code, sasInput);
|
||||
}
|
||||
|
||||
/// Format a SAS code as a 6-digit zero-padded string.
|
||||
String formatSas(int code) => code.toString().padLeft(6, '0');
|
||||
|
||||
/// Derive transcript hash binding all session parameters.
|
||||
/// transcript = session_id || source_pubkey || target_pubkey || sas_input (128 bytes)
|
||||
/// transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret, info="nostr-pair-transcript-v1", L=32)
|
||||
Uint8List deriveTranscriptHash(
|
||||
Uint8List sessionId,
|
||||
Uint8List sourcePubkey,
|
||||
Uint8List targetPubkey,
|
||||
Uint8List sasInput,
|
||||
Uint8List sessionSecret,
|
||||
) {
|
||||
final transcript = Uint8List(128);
|
||||
transcript.setRange(0, 32, sessionId);
|
||||
transcript.setRange(32, 64, sourcePubkey);
|
||||
transcript.setRange(64, 96, targetPubkey);
|
||||
transcript.setRange(96, 128, sasInput);
|
||||
|
||||
return hkdf(
|
||||
transcript,
|
||||
sessionSecret,
|
||||
Uint8List.fromList('nostr-pair-transcript-v1'.codeUnits),
|
||||
32,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a `nostrpair://` QR URI.
|
||||
({
|
||||
String sourcePubkey,
|
||||
Uint8List sessionSecret,
|
||||
List<String> relays,
|
||||
int version,
|
||||
})
|
||||
parseNostrpairUri(String uri) {
|
||||
if (uri.length > 2048) {
|
||||
throw FormatException('URI exceeds 2048-character limit');
|
||||
}
|
||||
|
||||
if (!uri.startsWith('nostrpair://')) {
|
||||
throw const FormatException('URI must start with nostrpair://');
|
||||
}
|
||||
|
||||
final rest = uri.substring('nostrpair://'.length);
|
||||
final qIdx = rest.indexOf('?');
|
||||
if (qIdx < 0) {
|
||||
throw const FormatException('Missing query string');
|
||||
}
|
||||
|
||||
final pubkeyHex = rest.substring(0, qIdx);
|
||||
final query = rest.substring(qIdx + 1);
|
||||
|
||||
// Validate pubkey: 64 lowercase hex chars.
|
||||
if (pubkeyHex.length != 64 || !_isLowercaseHex(pubkeyHex)) {
|
||||
throw const FormatException('Pubkey must be 64 lowercase hex chars');
|
||||
}
|
||||
|
||||
// Parse query params.
|
||||
String? secretHex;
|
||||
final relays = <String>[];
|
||||
int? version;
|
||||
|
||||
for (final pair in query.split('&')) {
|
||||
final eqIdx = pair.indexOf('=');
|
||||
if (eqIdx < 0) continue;
|
||||
final key = pair.substring(0, eqIdx);
|
||||
final value = pair.substring(eqIdx + 1);
|
||||
switch (key) {
|
||||
case 'secret':
|
||||
secretHex = value;
|
||||
case 'relay':
|
||||
relays.add(Uri.decodeComponent(value));
|
||||
case 'v':
|
||||
version = int.tryParse(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Default version to 1.
|
||||
version ??= 1;
|
||||
if (version != 1) {
|
||||
throw FormatException(
|
||||
'Unsupported protocol version $version. Please update the app.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate secret.
|
||||
if (secretHex == null ||
|
||||
secretHex.length != 64 ||
|
||||
!_isLowercaseHex(secretHex)) {
|
||||
throw const FormatException('Secret must be 64 lowercase hex chars');
|
||||
}
|
||||
final sessionSecret = hexToBytes(secretHex);
|
||||
|
||||
// Reject all-zeros.
|
||||
if (sessionSecret.every((b) => b == 0)) {
|
||||
throw const FormatException('Session secret must not be all zeros');
|
||||
}
|
||||
|
||||
if (relays.isEmpty) {
|
||||
throw const FormatException('At least one relay URL is required');
|
||||
}
|
||||
|
||||
// Validate relay URLs.
|
||||
for (final relay in relays) {
|
||||
final parsed = Uri.tryParse(relay);
|
||||
if (parsed == null || (parsed.scheme != 'wss' && parsed.scheme != 'ws')) {
|
||||
throw FormatException('Invalid relay URL: $relay');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
sourcePubkey: pubkeyHex,
|
||||
sessionSecret: sessionSecret,
|
||||
relays: relays,
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isLowercaseHex(String s) {
|
||||
for (final c in s.codeUnits) {
|
||||
if (!((c >= 0x30 && c <= 0x39) || (c >= 0x61 && c <= 0x66))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -14,143 +14,160 @@ class PairingPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pairingState = ref.watch(pairingProvider);
|
||||
final codeController = useTextEditingController();
|
||||
final isConnecting = pairingState.status == PairingStatus.connecting;
|
||||
final isBusy =
|
||||
pairingState.status == PairingStatus.connecting ||
|
||||
pairingState.status == PairingStatus.transferring ||
|
||||
pairingState.status == PairingStatus.storing;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
child: pairingState.status == PairingStatus.confirmingSas
|
||||
? _SasVerificationView(
|
||||
sasCode: pairingState.sasCode ?? '------',
|
||||
onConfirm: () =>
|
||||
ref.read(pairingProvider.notifier).confirmSas(),
|
||||
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// Branding
|
||||
Icon(LucideIcons.sprout, size: 64, color: context.colors.primary),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text('Welcome to Sprout', style: context.textTheme.headlineSmall),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Scan the QR code from your desktop app\nor paste a pairing code to connect.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Scan QR button
|
||||
FilledButton.icon(
|
||||
onPressed: isConnecting
|
||||
? null
|
||||
: () => _openScanner(context, ref),
|
||||
icon: const Icon(LucideIcons.scanLine),
|
||||
label: const Text('Scan QR Code'),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
// Divider
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
// Branding
|
||||
Icon(
|
||||
LucideIcons.sprout,
|
||||
size: 64,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
child: Text(
|
||||
'or paste pairing code',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
'Welcome to Sprout',
|
||||
style: context.textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Scan the QR code from your desktop app\nor paste a pairing code to connect.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.sm),
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Paste field
|
||||
TextField(
|
||||
controller: codeController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'sprout://...',
|
||||
prefixIcon: Icon(LucideIcons.link),
|
||||
isDense: true,
|
||||
),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: !isConnecting,
|
||||
contextMenuBuilder: (context, editableTextState) {
|
||||
return AdaptiveTextSelectionToolbar.editableText(
|
||||
editableTextState: editableTextState,
|
||||
);
|
||||
},
|
||||
),
|
||||
// Scan QR button
|
||||
FilledButton.icon(
|
||||
onPressed: isBusy
|
||||
? null
|
||||
: () => _openScanner(context, ref),
|
||||
icon: const Icon(LucideIcons.scanLine),
|
||||
label: const Text('Scan QR Code'),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.twelve),
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
// Connect button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: isConnecting
|
||||
? null
|
||||
: () {
|
||||
final code = codeController.text.trim();
|
||||
if (code.isNotEmpty) {
|
||||
ref.read(pairingProvider.notifier).pair(code);
|
||||
}
|
||||
},
|
||||
child: isConnecting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
// Divider
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'or paste pairing code',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (pairingState.status == PairingStatus.error &&
|
||||
pairingState.errorMessage != null) ...[
|
||||
const SizedBox(height: Grid.twelve),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.triangleAlert,
|
||||
size: 16,
|
||||
color: context.colors.onErrorContainer,
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
// Paste field
|
||||
TextField(
|
||||
controller: codeController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'nostrpair://... or sprout://...',
|
||||
prefixIcon: Icon(LucideIcons.link),
|
||||
isDense: true,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pairingState.errorMessage!,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onErrorContainer,
|
||||
),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enabled: !isBusy,
|
||||
contextMenuBuilder: (context, editableTextState) {
|
||||
return AdaptiveTextSelectionToolbar.editableText(
|
||||
editableTextState: editableTextState,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.twelve),
|
||||
|
||||
// Connect button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: isBusy
|
||||
? null
|
||||
: () {
|
||||
final code = codeController.text.trim();
|
||||
if (code.isNotEmpty) {
|
||||
ref.read(pairingProvider.notifier).pair(code);
|
||||
}
|
||||
},
|
||||
child: isBusy
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Connect'),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (pairingState.status == PairingStatus.error &&
|
||||
pairingState.errorMessage != null) ...[
|
||||
const SizedBox(height: Grid.twelve),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.triangleAlert,
|
||||
size: 16,
|
||||
color: context.colors.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pairingState.errorMessage!,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(flex: 3),
|
||||
],
|
||||
),
|
||||
const Spacer(flex: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -170,6 +187,103 @@ class PairingPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// SAS verification screen shown during NIP-AB pairing.
|
||||
class _SasVerificationView extends StatelessWidget {
|
||||
final String sasCode;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onDeny;
|
||||
|
||||
const _SasVerificationView({
|
||||
required this.sasCode,
|
||||
required this.onConfirm,
|
||||
required this.onDeny,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
Icon(LucideIcons.shieldCheck, size: 56, color: context.colors.primary),
|
||||
const SizedBox(height: Grid.sm),
|
||||
|
||||
Text('Verify Security Code', style: context.textTheme.headlineSmall),
|
||||
const SizedBox(height: Grid.xs),
|
||||
|
||||
Text(
|
||||
'Does your desktop app show this code?',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Large SAS code display
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
|
||||
style: context.textTheme.displayMedium?.copyWith(
|
||||
fontFamily: 'GeistMono',
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 8,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
Text(
|
||||
'You are about to transfer your Sprout identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Confirm / Deny buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onDeny,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
label: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.sm),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onConfirm,
|
||||
icon: const Icon(LucideIcons.check),
|
||||
label: const Text('Codes Match'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(flex: 3),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScannerPage extends HookWidget {
|
||||
final void Function(String code) onScanned;
|
||||
|
||||
|
||||
@@ -1,48 +1,497 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/crypto/ecdh.dart';
|
||||
import '../../shared/crypto/nip44.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import 'pairing_crypto.dart';
|
||||
import 'pairing_socket.dart';
|
||||
|
||||
/// HTTP client used by [PairingNotifier] for the validation request.
|
||||
/// Override in tests to inject a mock client.
|
||||
final pairingHttpClientProvider = Provider<http.Client>((ref) {
|
||||
final client = http.Client();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
});
|
||||
|
||||
enum PairingStatus { idle, connecting, success, error }
|
||||
enum PairingStatus {
|
||||
idle,
|
||||
connecting,
|
||||
confirmingSas,
|
||||
transferring,
|
||||
storing,
|
||||
success,
|
||||
error,
|
||||
}
|
||||
|
||||
class PairingState {
|
||||
final PairingStatus status;
|
||||
final String? errorMessage;
|
||||
final String? sasCode;
|
||||
|
||||
const PairingState({this.status = PairingStatus.idle, this.errorMessage});
|
||||
const PairingState({
|
||||
this.status = PairingStatus.idle,
|
||||
this.errorMessage,
|
||||
this.sasCode,
|
||||
});
|
||||
|
||||
PairingState copyWith({PairingStatus? status, String? errorMessage}) =>
|
||||
PairingState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
PairingState copyWith({
|
||||
PairingStatus? status,
|
||||
String? errorMessage,
|
||||
String? sasCode,
|
||||
}) => PairingState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
sasCode: sasCode ?? this.sasCode,
|
||||
);
|
||||
}
|
||||
|
||||
class PairingNotifier extends Notifier<PairingState> {
|
||||
PairingSocket? _socket;
|
||||
Timer? _sessionTimeout;
|
||||
|
||||
@override
|
||||
PairingState build() => const PairingState();
|
||||
|
||||
Future<void> pair(String rawInput) async {
|
||||
if (state.status == PairingStatus.connecting) return;
|
||||
if (state.status == PairingStatus.connecting ||
|
||||
state.status == PairingStatus.confirmingSas ||
|
||||
state.status == PairingStatus.transferring) {
|
||||
return;
|
||||
}
|
||||
|
||||
final trimmed = rawInput.trim();
|
||||
if (trimmed.startsWith('nostrpair://')) {
|
||||
return _pairNipAb(trimmed);
|
||||
}
|
||||
// Legacy sprout:// flow.
|
||||
return _pairLegacy(trimmed);
|
||||
}
|
||||
|
||||
/// Confirm that the SAS code matches. Called by the UI after user approval.
|
||||
void confirmSas() {
|
||||
if (state.status != PairingStatus.confirmingSas) return;
|
||||
state = state.copyWith(status: PairingStatus.transferring);
|
||||
|
||||
// The source sends sas-confirm + payload back-to-back. The payload may
|
||||
// have arrived while we were still in confirmingSas (before the user
|
||||
// tapped "Codes Match"). Process the buffered payload now.
|
||||
final pending = _pendingPayload;
|
||||
if (pending != null) {
|
||||
_pendingPayload = null;
|
||||
_handlePayload(pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deny the SAS code. Send abort and terminate.
|
||||
void denySas() {
|
||||
_sendAbort('sas_mismatch');
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'SAS code mismatch — pairing cancelled for security.',
|
||||
);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_cleanup();
|
||||
state = const PairingState();
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_sessionTimeout?.cancel();
|
||||
_sessionTimeout = null;
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
_processedEventIds.clear();
|
||||
_sasConfirmReceived = false;
|
||||
_pendingPayload = null;
|
||||
}
|
||||
|
||||
// ── NIP-AB pairing flow ─────────────────────────────────────────────────
|
||||
|
||||
// Session state kept between steps.
|
||||
String? _ephemeralPrivkey;
|
||||
String? _ephemeralPubkey;
|
||||
Uint8List? _sessionSecret;
|
||||
String? _sourcePubkey;
|
||||
Uint8List? _sessionId;
|
||||
Uint8List? _sasInput;
|
||||
Uint8List? _conversationKey;
|
||||
bool _sasConfirmReceived = false;
|
||||
Map<String, dynamic>? _pendingPayload; // buffered until user confirms SAS
|
||||
final Set<String> _processedEventIds = {}; // NIP-AB §Duplicate Event Handling
|
||||
|
||||
Future<void> _pairNipAb(String uri) async {
|
||||
state = const PairingState(status: PairingStatus.connecting);
|
||||
|
||||
try {
|
||||
final creds = _parseInput(rawInput);
|
||||
// 1. Parse the nostrpair:// URI.
|
||||
final qr = parseNostrpairUri(uri);
|
||||
_sourcePubkey = qr.sourcePubkey;
|
||||
_sessionSecret = qr.sessionSecret;
|
||||
|
||||
final relayWsUrl = qr.relays.first;
|
||||
|
||||
// 2. Generate ephemeral keypair.
|
||||
final keychain = nostr.Keychain.generate();
|
||||
_ephemeralPrivkey = keychain.private;
|
||||
_ephemeralPubkey = keychain.public;
|
||||
|
||||
// 3. Derive session ID and SAS immediately (we know source pubkey from QR).
|
||||
_sessionId = deriveSessionId(qr.sessionSecret);
|
||||
final ecdhShared = ecdhSharedSecret(_ephemeralPrivkey!, qr.sourcePubkey);
|
||||
final (sasCode, sasInput) = deriveSas(ecdhShared, qr.sessionSecret);
|
||||
_sasInput = sasInput;
|
||||
|
||||
// Pre-compute NIP-44 conversation key for encrypting events.
|
||||
_conversationKey = getConversationKey(
|
||||
_ephemeralPrivkey!,
|
||||
qr.sourcePubkey,
|
||||
);
|
||||
|
||||
// 4. Connect to relay with ephemeral keys.
|
||||
_socket = PairingSocket(
|
||||
wsUrl: relayWsUrl,
|
||||
ephemeralPrivkey: _ephemeralPrivkey!,
|
||||
onMessage: _handleRelayMessage,
|
||||
onDisconnected: _handleDisconnected,
|
||||
);
|
||||
await _socket!.connect();
|
||||
|
||||
if (!_socket!.isConnected) {
|
||||
throw Exception('Failed to connect to pairing relay');
|
||||
}
|
||||
|
||||
// 5. Subscribe for kind:24134 events tagged to our ephemeral pubkey.
|
||||
_socket!.subscribe('pair', 24134, _ephemeralPubkey!);
|
||||
|
||||
// 6. Wait briefly for EOSE, then send offer.
|
||||
// (In practice, we send the offer immediately — the relay will buffer it.)
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// 7. Build and send the offer event.
|
||||
final offerContent = _encryptMessage({
|
||||
'type': 'offer',
|
||||
'version': 1,
|
||||
'session_id': bytesToHex(_sessionId!),
|
||||
});
|
||||
|
||||
_publishEvent(
|
||||
kind: 24134,
|
||||
content: offerContent,
|
||||
tags: [
|
||||
['p', qr.sourcePubkey],
|
||||
],
|
||||
);
|
||||
|
||||
// 8. Display SAS code and wait for sas-confirm from source.
|
||||
state = PairingState(
|
||||
status: PairingStatus.confirmingSas,
|
||||
sasCode: formatSas(sasCode),
|
||||
);
|
||||
|
||||
// 9. Start 120s session timeout.
|
||||
_sessionTimeout = Timer(const Duration(seconds: 120), () {
|
||||
if (state.status != PairingStatus.success &&
|
||||
state.status != PairingStatus.error) {
|
||||
_cleanup();
|
||||
state = const PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Pairing session timed out.',
|
||||
);
|
||||
}
|
||||
});
|
||||
} on FormatException catch (e) {
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Invalid pairing code: ${e.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Connection failed: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleRelayMessage(List<dynamic> data) {
|
||||
if (data.isEmpty) return;
|
||||
final type = data[0] as String;
|
||||
|
||||
if (type == 'EVENT' && data.length >= 3) {
|
||||
final eventJson = data[2] as Map<String, dynamic>;
|
||||
_handlePairingEvent(eventJson);
|
||||
}
|
||||
// Ignore EOSE, NOTICE, etc.
|
||||
}
|
||||
|
||||
void _handlePairingEvent(Map<String, dynamic> eventJson) {
|
||||
try {
|
||||
// NIP-AB §Event Validation: validate kind.
|
||||
final kind = eventJson['kind'] as int?;
|
||||
if (kind != 24134) return;
|
||||
|
||||
// NIP-AB §Event Validation: validate pubkey is from expected source.
|
||||
final eventPubkey = eventJson['pubkey'] as String?;
|
||||
if (eventPubkey == null) return;
|
||||
if (_sourcePubkey != null && eventPubkey != _sourcePubkey) return;
|
||||
|
||||
// NIP-AB §Duplicate Event Handling: discard already-processed events.
|
||||
final eventId = eventJson['id'] as String?;
|
||||
if (eventId == null) return;
|
||||
if (_processedEventIds.contains(eventId)) return;
|
||||
|
||||
// NIP-AB §Event Validation: check p-tag points to us.
|
||||
final tags = (eventJson['tags'] as List<dynamic>?) ?? [];
|
||||
final hasOurPTag = tags.any((t) {
|
||||
if (t is List && t.length >= 2) {
|
||||
return t[0] == 'p' && t[1] == _ephemeralPubkey;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!hasOurPTag) return;
|
||||
|
||||
// NIP-AB §Event Validation: verify event signature (NIP-01).
|
||||
// The nostr package's Event.fromJson verifies id + sig on construction.
|
||||
try {
|
||||
final event = nostr.Event.fromJson(eventJson);
|
||||
if (event.id != eventId) return; // id mismatch
|
||||
} catch (_) {
|
||||
return; // invalid signature or malformed event
|
||||
}
|
||||
|
||||
// Decrypt NIP-44 content.
|
||||
final content = eventJson['content'] as String?;
|
||||
if (content == null || content.isEmpty) return;
|
||||
|
||||
final decryptKey = getConversationKey(_ephemeralPrivkey!, eventPubkey);
|
||||
final decrypted = nip44Decrypt(decryptKey, content);
|
||||
final msg = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||
final msgType = msg['type'] as String?;
|
||||
|
||||
switch (msgType) {
|
||||
case 'sas-confirm':
|
||||
_handleSasConfirm(msg);
|
||||
_processedEventIds.add(eventId); // record after successful processing
|
||||
case 'payload':
|
||||
_handlePayload(msg);
|
||||
_processedEventIds.add(eventId);
|
||||
case 'abort':
|
||||
_handleAbort(msg);
|
||||
_processedEventIds.add(eventId);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently discard invalid events per NIP-AB §Event Validation.
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSasConfirm(Map<String, dynamic> msg) {
|
||||
if (state.status != PairingStatus.confirmingSas) return;
|
||||
|
||||
final receivedHash = msg['transcript_hash'] as String?;
|
||||
if (receivedHash == null) return;
|
||||
|
||||
// Verify transcript hash.
|
||||
final expectedHash = deriveTranscriptHash(
|
||||
_sessionId!,
|
||||
hexToBytes(_sourcePubkey!),
|
||||
hexToBytes(_ephemeralPubkey!),
|
||||
_sasInput!,
|
||||
_sessionSecret!,
|
||||
);
|
||||
|
||||
final receivedBytes = hexToBytes(receivedHash);
|
||||
if (!constantTimeEquals(receivedBytes, expectedHash)) {
|
||||
// NIP-AB §Step 3: target MUST send abort with reason "sas_mismatch".
|
||||
_sendAbort('sas_mismatch');
|
||||
_cleanup();
|
||||
state = const PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage:
|
||||
'Security verification failed — possible attack. Pairing aborted.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_sasConfirmReceived = true;
|
||||
// Stay in confirmingSas — user must still explicitly confirm via confirmSas().
|
||||
}
|
||||
|
||||
void _handlePayload(Map<String, dynamic> msg) {
|
||||
// Only accept payload after the transcript hash was verified.
|
||||
if (!_sasConfirmReceived) return;
|
||||
|
||||
// If the user hasn't confirmed SAS yet, buffer the payload.
|
||||
// It will be processed when confirmSas() is called.
|
||||
if (state.status == PairingStatus.confirmingSas) {
|
||||
_pendingPayload = msg;
|
||||
return;
|
||||
}
|
||||
if (state.status != PairingStatus.transferring) return;
|
||||
|
||||
state = state.copyWith(status: PairingStatus.storing);
|
||||
|
||||
final payloadType = msg['payload_type'] as String?;
|
||||
final payload = msg['payload'] as String?;
|
||||
if (payload == null) {
|
||||
state = const PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Received empty payload from source.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_processPayload(payloadType, payload);
|
||||
}
|
||||
|
||||
void _handleAbort(Map<String, dynamic> msg) {
|
||||
final reason = msg['reason'] as String? ?? 'unknown';
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Source device aborted pairing: $reason',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _processPayload(String? payloadType, String payload) async {
|
||||
try {
|
||||
// Parse the custom payload.
|
||||
final data = jsonDecode(payload) as Map<String, dynamic>;
|
||||
final relayUrl = data['relayUrl'] as String?;
|
||||
final token = data['token'] as String?;
|
||||
final pubkey = data['pubkey'] as String?;
|
||||
final nsec = data['nsec'] as String?;
|
||||
|
||||
if (relayUrl == null || token == null) {
|
||||
throw const FormatException('Missing relayUrl or token in payload');
|
||||
}
|
||||
|
||||
// Send complete event.
|
||||
_sendComplete(true);
|
||||
|
||||
// Validate credentials against the relay.
|
||||
final client = RelayClient(
|
||||
baseUrl: relayUrl,
|
||||
apiToken: token,
|
||||
httpClient: ref.read(pairingHttpClientProvider),
|
||||
);
|
||||
try {
|
||||
await client.get('/api/users/me/profile');
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
|
||||
// Store credentials.
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.authenticate(
|
||||
StoredCredentials(
|
||||
relayUrl: relayUrl,
|
||||
token: token,
|
||||
pubkey: pubkey,
|
||||
nsec: nsec,
|
||||
),
|
||||
);
|
||||
|
||||
_cleanup();
|
||||
state = const PairingState(status: PairingStatus.success);
|
||||
} catch (e) {
|
||||
_sendComplete(false);
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Failed to import credentials: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _sendAbort(String reason) {
|
||||
try {
|
||||
final content = _encryptMessage({'type': 'abort', 'reason': reason});
|
||||
_publishEvent(
|
||||
kind: 24134,
|
||||
content: content,
|
||||
tags: [
|
||||
['p', _sourcePubkey!],
|
||||
],
|
||||
);
|
||||
} catch (_) {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
void _sendComplete(bool success) {
|
||||
try {
|
||||
final content = _encryptMessage({'type': 'complete', 'success': success});
|
||||
_publishEvent(
|
||||
kind: 24134,
|
||||
content: content,
|
||||
tags: [
|
||||
['p', _sourcePubkey!],
|
||||
],
|
||||
);
|
||||
} catch (_) {
|
||||
// Best-effort — complete is advisory per NIP-AB.
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt a message using NIP-44 with the ephemeral conversation key.
|
||||
String _encryptMessage(Map<String, dynamic> message) {
|
||||
final plaintext = jsonEncode(message);
|
||||
return nip44Encrypt(_conversationKey!, plaintext);
|
||||
}
|
||||
|
||||
/// Build and publish a kind:24134 event signed with ephemeral keys.
|
||||
void _publishEvent({
|
||||
required int kind,
|
||||
required String content,
|
||||
required List<List<String>> tags,
|
||||
}) {
|
||||
// Add timestamp jitter (0-30s) for metadata privacy.
|
||||
final jitter = math.Random.secure().nextInt(31);
|
||||
final createdAt = (DateTime.now().millisecondsSinceEpoch ~/ 1000) - jitter;
|
||||
|
||||
final event = nostr.Event.from(
|
||||
kind: kind,
|
||||
content: content,
|
||||
tags: tags,
|
||||
privkey: _ephemeralPrivkey!,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
_socket?.publishEvent(event.toJson());
|
||||
}
|
||||
|
||||
void _handleDisconnected(Object? error) {
|
||||
if (state.status == PairingStatus.success ||
|
||||
state.status == PairingStatus.error) {
|
||||
return;
|
||||
}
|
||||
_cleanup();
|
||||
state = PairingState(
|
||||
status: PairingStatus.error,
|
||||
errorMessage: 'Lost connection to pairing relay.',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Legacy sprout:// flow ───────────────────────────────────────────────
|
||||
|
||||
Future<void> _pairLegacy(String rawInput) async {
|
||||
state = const PairingState(status: PairingStatus.connecting);
|
||||
|
||||
try {
|
||||
final creds = _parseLegacyInput(rawInput);
|
||||
|
||||
// Test the connection before storing.
|
||||
final client = RelayClient(
|
||||
baseUrl: creds.relayUrl,
|
||||
apiToken: creds.token,
|
||||
@@ -78,18 +527,13 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const PairingState();
|
||||
}
|
||||
|
||||
StoredCredentials _parseInput(String raw) {
|
||||
StoredCredentials _parseLegacyInput(String raw) {
|
||||
var payload = raw.trim();
|
||||
|
||||
if (payload.startsWith('sprout://')) {
|
||||
payload = payload.substring('sprout://'.length);
|
||||
}
|
||||
|
||||
// base64url decode.
|
||||
final normalized = base64Url.normalize(payload);
|
||||
final jsonStr = utf8.decode(base64Url.decode(normalized));
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
@@ -113,8 +557,6 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Reject relay URLs that aren't HTTPS (unless debug mode) or target
|
||||
/// private/link-local addresses.
|
||||
void _validateRelayUrl(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
@@ -133,7 +575,6 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Block private and link-local IPs.
|
||||
final ip = Uri.tryParse('http://$host')?.host ?? host;
|
||||
if (_isPrivateHost(ip)) {
|
||||
throw const FormatException(
|
||||
@@ -151,13 +592,9 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
final a = octets[0]!;
|
||||
final b = octets[1]!;
|
||||
|
||||
// 10.0.0.0/8
|
||||
if (a == 10) return true;
|
||||
// 172.16.0.0/12
|
||||
if (a == 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (a == 192 && b == 168) return true;
|
||||
// 169.254.0.0/16 (link-local / cloud metadata)
|
||||
if (a == 169 && b == 254) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
203
mobile/lib/features/pairing/pairing_socket.dart
Normal file
203
mobile/lib/features/pairing/pairing_socket.dart
Normal file
@@ -0,0 +1,203 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../shared/relay/nostr_models.dart';
|
||||
|
||||
/// Ephemeral WebSocket connection for NIP-AB pairing.
|
||||
///
|
||||
/// Uses ephemeral keys for NIP-42 auth (not the stored user keys).
|
||||
/// Single-use — disposed after the pairing session completes.
|
||||
class PairingSocket {
|
||||
final String _wsUrl;
|
||||
final String _ephemeralPrivkey;
|
||||
final void Function(List<dynamic> message) _onMessage;
|
||||
final void Function(Object? error) _onDisconnected;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
Completer<void>? _authCompleter;
|
||||
Timer? _authTimeout;
|
||||
String? _pendingAuthEventId;
|
||||
bool _connected = false;
|
||||
|
||||
PairingSocket({
|
||||
required String wsUrl,
|
||||
required String ephemeralPrivkey,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) : _wsUrl = wsUrl,
|
||||
_ephemeralPrivkey = ephemeralPrivkey,
|
||||
_onMessage = onMessage,
|
||||
_onDisconnected = onDisconnected;
|
||||
|
||||
bool get isConnected => _connected;
|
||||
|
||||
/// Connect and authenticate via NIP-42.
|
||||
Future<void> connect() async {
|
||||
try {
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
|
||||
await _channel!.ready;
|
||||
} catch (e) {
|
||||
_onDisconnected(e);
|
||||
return;
|
||||
}
|
||||
|
||||
_authCompleter = Completer<void>();
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleRawMessage,
|
||||
onError: (Object error) {
|
||||
_failAuth(error);
|
||||
_onDisconnected(error);
|
||||
},
|
||||
onDone: () {
|
||||
_failAuth(null);
|
||||
_onDisconnected(null);
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for auth with 8s timeout.
|
||||
_authTimeout = Timer(const Duration(seconds: 8), () {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.completeError(
|
||||
TimeoutException('NIP-42 auth timed out'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await _authCompleter!.future;
|
||||
_authTimeout?.cancel();
|
||||
_connected = true;
|
||||
} catch (e) {
|
||||
_authTimeout?.cancel();
|
||||
await disconnect();
|
||||
_onDisconnected(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a raw JSON array.
|
||||
void send(List<dynamic> payload) {
|
||||
_channel?.sink.add(jsonEncode(payload));
|
||||
}
|
||||
|
||||
/// Send a subscribe request.
|
||||
void subscribe(String subId, int kind, String pubkeyHex) {
|
||||
send([
|
||||
'REQ',
|
||||
subId,
|
||||
{
|
||||
'kinds': [kind],
|
||||
'#p': [pubkeyHex],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/// Publish a Nostr event (already JSON-encoded map).
|
||||
void publishEvent(Map<String, dynamic> event) {
|
||||
send(['EVENT', event]);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_connected = false;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_authTimeout?.cancel();
|
||||
final channel = _channel;
|
||||
_channel = null;
|
||||
if (channel != null) {
|
||||
await channel.sink.close();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_connected = false;
|
||||
_subscription?.cancel();
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
_authTimeout?.cancel();
|
||||
}
|
||||
|
||||
void _failAuth(Object? error) {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.completeError(error ?? Exception('Connection closed'));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleRawMessage(dynamic raw) {
|
||||
if (raw is! String) return;
|
||||
|
||||
final List<dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(raw) as List<dynamic>;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.isEmpty) return;
|
||||
final type = data[0] as String;
|
||||
|
||||
switch (type) {
|
||||
case 'AUTH':
|
||||
_handleAuthChallenge(data);
|
||||
case 'OK':
|
||||
_handleOk(data);
|
||||
default:
|
||||
// Pass EVENT, EOSE, NOTICE upstream.
|
||||
_onMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleAuthChallenge(List<dynamic> data) {
|
||||
if (data.length < 2) return;
|
||||
final challenge = data[1] as String;
|
||||
|
||||
try {
|
||||
// Build NIP-42 auth event (kind:22242) with ephemeral keys.
|
||||
final tags = <List<String>>[
|
||||
['relay', _wsUrl],
|
||||
['challenge', challenge],
|
||||
];
|
||||
|
||||
final event = nostr.Event.from(
|
||||
kind: EventKind.auth,
|
||||
content: '',
|
||||
tags: tags,
|
||||
privkey: _ephemeralPrivkey,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
_pendingAuthEventId = event.id;
|
||||
send(['AUTH', event.toJson()]);
|
||||
} catch (e) {
|
||||
_failAuth(e);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleOk(List<dynamic> data) {
|
||||
if (data.length < 3) return;
|
||||
final eventId = data[1] as String;
|
||||
final accepted = data[2] as bool;
|
||||
|
||||
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
|
||||
_pendingAuthEventId = null;
|
||||
if (accepted) {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.complete();
|
||||
}
|
||||
} else {
|
||||
final message = data.length > 3
|
||||
? data[3] as String
|
||||
: 'Auth rejected by relay';
|
||||
_failAuth(Exception(message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass non-auth OK upstream.
|
||||
_onMessage(data);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
@@ -36,6 +38,47 @@ class SettingsPage extends HookConsumerWidget {
|
||||
side: BorderSide(color: context.colors.outlineVariant),
|
||||
),
|
||||
),
|
||||
if (config.nsec != null && config.nsec!.isNotEmpty) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final privHex = nostr.Nip19.decodePrivkey(config.nsec!);
|
||||
final pubkey = privHex.isNotEmpty
|
||||
? nostr.Keychain(privHex).public
|
||||
: 'unknown';
|
||||
return ListTile(
|
||||
leading: const Icon(LucideIcons.key),
|
||||
title: const Text('Identity (pubkey)'),
|
||||
subtitle: Text(
|
||||
pubkey,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontFamily: 'GeistMono',
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(LucideIcons.copy, size: 16),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: pubkey));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Pubkey copied'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
side: BorderSide(color: context.colors.outlineVariant),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.twelve),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _confirmSignOut(context, ref),
|
||||
@@ -128,7 +171,10 @@ class SettingsPage extends HookConsumerWidget {
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
Navigator.of(ctx).pop(); // close dialog
|
||||
// Pop all pushed routes back to root so MaterialApp.home
|
||||
// rebuilds to PairingPage when auth state changes.
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
ref.read(authProvider.notifier).signOut();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
|
||||
65
mobile/lib/shared/crypto/ecdh.dart
Normal file
65
mobile/lib/shared/crypto/ecdh.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:pointycastle/ecc/curves/secp256k1.dart';
|
||||
|
||||
/// Compute secp256k1 ECDH shared secret (raw x-coordinate, unhashed).
|
||||
///
|
||||
/// Both [privateKeyHex] and [publicKeyHex] are 64-char lowercase hex strings
|
||||
/// (32 bytes each). The public key is an x-only BIP-340 key.
|
||||
///
|
||||
/// Returns the 32-byte x-coordinate of the shared point.
|
||||
Uint8List ecdhSharedSecret(String privateKeyHex, String publicKeyHex) {
|
||||
final params = ECCurve_secp256k1();
|
||||
|
||||
// Parse private key scalar.
|
||||
final d = BigInt.parse(privateKeyHex, radix: 16);
|
||||
|
||||
// Lift x-only public key to a full curve point (assume even y / prefix 02).
|
||||
final xBytes = hexToBytes(publicKeyHex);
|
||||
final compressed = Uint8List(33);
|
||||
compressed[0] = 0x02; // even y
|
||||
compressed.setRange(1, 33, xBytes);
|
||||
final pubPoint = params.curve.decodePoint(compressed);
|
||||
if (pubPoint == null) {
|
||||
throw ArgumentError('Invalid public key: cannot decode point');
|
||||
}
|
||||
|
||||
// Scalar multiplication: shared = d * Q
|
||||
final shared = pubPoint * d;
|
||||
if (shared == null || shared.isInfinity) {
|
||||
throw StateError('ECDH produced point at infinity');
|
||||
}
|
||||
|
||||
// Extract 32-byte x-coordinate (big-endian, zero-padded).
|
||||
return bigIntTo32Bytes(shared.x!.toBigInteger()!);
|
||||
}
|
||||
|
||||
/// Generate secure random bytes.
|
||||
Uint8List secureRandomBytes(int length) {
|
||||
final rng = math.Random.secure();
|
||||
final data = Uint8List(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
data[i] = rng.nextInt(256);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Hex helpers (shared across crypto modules) ──────────────────────────────
|
||||
|
||||
Uint8List hexToBytes(String hex) {
|
||||
final result = Uint8List(hex.length ~/ 2);
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
Uint8List bigIntTo32Bytes(BigInt value) {
|
||||
final hexStr = value.toRadixString(16).padLeft(64, '0');
|
||||
return hexToBytes(hexStr);
|
||||
}
|
||||
44
mobile/lib/shared/crypto/hkdf.dart
Normal file
44
mobile/lib/shared/crypto/hkdf.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:pointycastle/macs/hmac.dart';
|
||||
|
||||
/// HKDF-SHA256 Extract: PRK = HMAC-SHA256(salt, ikm).
|
||||
Uint8List hkdfExtract(Uint8List salt, Uint8List ikm) {
|
||||
final hmac = HMac(SHA256Digest(), 64);
|
||||
hmac.init(KeyParameter(salt.isEmpty ? Uint8List(32) : salt));
|
||||
final out = Uint8List(32);
|
||||
hmac.update(ikm, 0, ikm.length);
|
||||
hmac.doFinal(out, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// HKDF-SHA256 Expand: OKM = HKDF-Expand(prk, info, length).
|
||||
Uint8List hkdfExpand(Uint8List prk, Uint8List info, int length) {
|
||||
final hmac = HMac(SHA256Digest(), 64);
|
||||
hmac.init(KeyParameter(prk));
|
||||
|
||||
final n = (length + 31) ~/ 32; // ceil(length / hashLen)
|
||||
final okm = Uint8List(n * 32);
|
||||
var prev = Uint8List(0);
|
||||
|
||||
for (var i = 1; i <= n; i++) {
|
||||
hmac.reset();
|
||||
hmac.update(prev, 0, prev.length);
|
||||
hmac.update(info, 0, info.length);
|
||||
final counter = Uint8List.fromList([i]);
|
||||
hmac.update(counter, 0, 1);
|
||||
prev = Uint8List(32);
|
||||
hmac.doFinal(prev, 0);
|
||||
okm.setRange((i - 1) * 32, i * 32, prev);
|
||||
}
|
||||
|
||||
return Uint8List.sublistView(okm, 0, length);
|
||||
}
|
||||
|
||||
/// Convenience: HKDF-SHA256(ikm, salt, info, length).
|
||||
Uint8List hkdf(Uint8List ikm, Uint8List salt, Uint8List info, int length) {
|
||||
final prk = hkdfExtract(salt, ikm);
|
||||
return hkdfExpand(prk, info, length);
|
||||
}
|
||||
176
mobile/lib/shared/crypto/nip44.dart
Normal file
176
mobile/lib/shared/crypto/nip44.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:pointycastle/macs/hmac.dart';
|
||||
import 'package:pointycastle/stream/chacha7539.dart';
|
||||
|
||||
import 'ecdh.dart';
|
||||
import 'hkdf.dart';
|
||||
|
||||
/// NIP-44 v2 conversation key derivation.
|
||||
///
|
||||
/// conversation_key = HKDF-Extract(salt="nip44-v2", ikm=ecdh_shared_secret)
|
||||
Uint8List getConversationKey(String senderPrivHex, String receiverPubHex) {
|
||||
final shared = ecdhSharedSecret(senderPrivHex, receiverPubHex);
|
||||
final salt = utf8.encode('nip44-v2');
|
||||
return hkdfExtract(Uint8List.fromList(salt), shared);
|
||||
}
|
||||
|
||||
/// NIP-44 v2 encrypt.
|
||||
///
|
||||
/// Returns base64-encoded payload: version(1) || nonce(32) || ciphertext || mac(32)
|
||||
String nip44Encrypt(Uint8List conversationKey, String plaintext) {
|
||||
final plaintextBytes = utf8.encode(plaintext);
|
||||
if (plaintextBytes.isEmpty || plaintextBytes.length > 65535) {
|
||||
throw ArgumentError('Plaintext must be 1-65535 bytes');
|
||||
}
|
||||
|
||||
// Pad plaintext.
|
||||
final padded = _pad(Uint8List.fromList(plaintextBytes));
|
||||
|
||||
// Generate 32-byte nonce.
|
||||
final nonce = secureRandomBytes(32);
|
||||
|
||||
// Derive message keys: chacha_key(32) + chacha_nonce(12) + hmac_key(32) = 76
|
||||
final messageKeys = hkdfExpand(conversationKey, nonce, 76);
|
||||
final chachaKey = Uint8List.sublistView(messageKeys, 0, 32);
|
||||
final chachaNonce = Uint8List.sublistView(messageKeys, 32, 44);
|
||||
final hmacKey = Uint8List.sublistView(messageKeys, 44, 76);
|
||||
|
||||
// Encrypt with ChaCha20.
|
||||
final ciphertext = _chacha20(chachaKey, chachaNonce, padded);
|
||||
|
||||
// HMAC-SHA256 over nonce || ciphertext.
|
||||
final mac = _hmacSha256(hmacKey, _concat([nonce, ciphertext]));
|
||||
|
||||
// Assemble: version(0x02) || nonce(32) || ciphertext || mac(32)
|
||||
final payload = _concat([
|
||||
Uint8List.fromList([0x02]),
|
||||
nonce,
|
||||
ciphertext,
|
||||
mac,
|
||||
]);
|
||||
|
||||
return base64.encode(payload);
|
||||
}
|
||||
|
||||
/// NIP-44 v2 decrypt.
|
||||
///
|
||||
/// Takes base64-encoded payload, returns plaintext string.
|
||||
String nip44Decrypt(Uint8List conversationKey, String payloadBase64) {
|
||||
final payload = base64.decode(payloadBase64);
|
||||
|
||||
// Minimum: version(1) + nonce(32) + min_ciphertext(32) + mac(32) = 97
|
||||
if (payload.length < 97) {
|
||||
throw FormatException('NIP-44 payload too short: ${payload.length}');
|
||||
}
|
||||
|
||||
// Check version byte.
|
||||
if (payload[0] != 0x02) {
|
||||
throw FormatException('NIP-44 unsupported version: ${payload[0]}');
|
||||
}
|
||||
|
||||
final nonce = Uint8List.sublistView(payload, 1, 33);
|
||||
final ciphertext = Uint8List.sublistView(payload, 33, payload.length - 32);
|
||||
final receivedMac = Uint8List.sublistView(payload, payload.length - 32);
|
||||
|
||||
// Derive message keys.
|
||||
final messageKeys = hkdfExpand(conversationKey, nonce, 76);
|
||||
final chachaKey = Uint8List.sublistView(messageKeys, 0, 32);
|
||||
final chachaNonce = Uint8List.sublistView(messageKeys, 32, 44);
|
||||
final hmacKey = Uint8List.sublistView(messageKeys, 44, 76);
|
||||
|
||||
// Verify HMAC.
|
||||
final expectedMac = _hmacSha256(hmacKey, _concat([nonce, ciphertext]));
|
||||
if (!constantTimeEquals(receivedMac, expectedMac)) {
|
||||
throw FormatException('NIP-44 HMAC verification failed');
|
||||
}
|
||||
|
||||
// Decrypt with ChaCha20.
|
||||
final padded = _chacha20(chachaKey, chachaNonce, ciphertext);
|
||||
|
||||
// Unpad.
|
||||
return _unpad(padded);
|
||||
}
|
||||
|
||||
// ── Padding ─────────────────────────────────────────────────────────────────
|
||||
|
||||
int _calcPaddedLen(int unpaddedLen) {
|
||||
if (unpaddedLen <= 0) throw ArgumentError('Length must be > 0');
|
||||
if (unpaddedLen <= 32) return 32;
|
||||
final nextPower = 1 << (_log2(unpaddedLen - 1) + 1);
|
||||
final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8;
|
||||
return chunk * (((unpaddedLen - 1) ~/ chunk) + 1);
|
||||
}
|
||||
|
||||
int _log2(int x) {
|
||||
var result = 0;
|
||||
while ((1 << (result + 1)) <= x) {
|
||||
result++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Uint8List _pad(Uint8List plaintext) {
|
||||
final len = plaintext.length;
|
||||
final paddedLen = _calcPaddedLen(len);
|
||||
// Result: [2-byte BE length][plaintext][zero padding]
|
||||
final result = Uint8List(2 + paddedLen);
|
||||
result[0] = (len >> 8) & 0xFF;
|
||||
result[1] = len & 0xFF;
|
||||
result.setRange(2, 2 + len, plaintext);
|
||||
return result;
|
||||
}
|
||||
|
||||
String _unpad(Uint8List padded) {
|
||||
if (padded.length < 2) throw FormatException('Padded data too short');
|
||||
final len = (padded[0] << 8) | padded[1];
|
||||
if (len == 0 || 2 + len > padded.length) {
|
||||
throw FormatException('Invalid padding length: $len');
|
||||
}
|
||||
return utf8.decode(padded.sublist(2, 2 + len));
|
||||
}
|
||||
|
||||
// ── ChaCha20 (IETF, 12-byte nonce) ─────────────────────────────────────────
|
||||
|
||||
Uint8List _chacha20(Uint8List key, Uint8List nonce, Uint8List data) {
|
||||
final cipher = ChaCha7539Engine();
|
||||
cipher.init(false, ParametersWithIV(KeyParameter(key), nonce));
|
||||
return cipher.process(data);
|
||||
}
|
||||
|
||||
// ── HMAC-SHA256 ─────────────────────────────────────────────────────────────
|
||||
|
||||
Uint8List _hmacSha256(Uint8List key, Uint8List data) {
|
||||
final hmac = HMac(SHA256Digest(), 64);
|
||||
hmac.init(KeyParameter(key));
|
||||
hmac.update(data, 0, data.length);
|
||||
final out = Uint8List(32);
|
||||
hmac.doFinal(out, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Uint8List _concat(List<Uint8List> parts) {
|
||||
final totalLen = parts.fold<int>(0, (sum, p) => sum + p.length);
|
||||
final result = Uint8List(totalLen);
|
||||
var offset = 0;
|
||||
for (final part in parts) {
|
||||
result.setRange(offset, offset + part.length, part);
|
||||
offset += part.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Constant-time byte comparison to prevent timing side-channels.
|
||||
bool constantTimeEquals(Uint8List a, Uint8List b) {
|
||||
if (a.length != b.length) return false;
|
||||
var result = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
result |= a[i] ^ b[i];
|
||||
}
|
||||
return result == 0;
|
||||
}
|
||||
@@ -721,7 +721,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies:
|
||||
web_socket_channel: ^3.0.1
|
||||
connectivity_plus: ^6.1.4
|
||||
nostr: ^1.5.0
|
||||
pointycastle: ^3.7.3
|
||||
url_launcher: ^6.3.2
|
||||
gpt_markdown: ^1.1.6
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ class _ErrorPairingNotifier extends Notifier<PairingState>
|
||||
|
||||
@override
|
||||
void reset() {}
|
||||
|
||||
@override
|
||||
void confirmSas() {}
|
||||
|
||||
@override
|
||||
void denySas() {}
|
||||
}
|
||||
|
||||
class _ConnectingPairingNotifier extends Notifier<PairingState>
|
||||
@@ -129,4 +135,10 @@ class _ConnectingPairingNotifier extends Notifier<PairingState>
|
||||
|
||||
@override
|
||||
void reset() {}
|
||||
|
||||
@override
|
||||
void confirmSas() {}
|
||||
|
||||
@override
|
||||
void denySas() {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user