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:
Wes
2026-04-16 16:25:18 -06:00
committed by GitHub
parent 0c5904b9a2
commit fc67dac4c6
21 changed files with 2248 additions and 250 deletions

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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" }

View File

@@ -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::*;

View 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())?
}

View File

@@ -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]);

View File

@@ -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");

View File

@@ -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>
);

View File

@@ -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");
}

View File

@@ -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),
);
});

View 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;
}

View File

@@ -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;

View File

@@ -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;
}

View 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);
}
}

View File

@@ -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(

View 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);
}

View 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);
}

View 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;
}

View File

@@ -721,7 +721,7 @@ packages:
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
dependency: "direct main"
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"

View File

@@ -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

View File

@@ -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() {}
}