feat(signer): QR pairing — client-initiated nostrconnect:// flow for Amber

Keynctr is the NIP-46 client; Amber is the scanner. Amber hands out no
link — it scans one — so the signer screen now mints a pairing token:

- start_pairing(): ephemeral key + secret, nostrconnect:// token via
  NostrConnectUri::client_with_secret, status().pairing_uri for the GUI
- run_pairing_task(): listens for the signer's connect request, echoes
  the secret (anti-spoofing), persists the connection row + secret,
  then adopts identity via get_public_key and hands to the demux loop
- pairing subscription is closed at handoff so the demux loop owns the
  conversation (relay could otherwise deliver signer replies under the
  stale pairing sub id where nobody routes them)
- IPC: nip46_pair_start; status carries pairing_uri
- SignerModeScreen: 'Show QR' button, QR render (qrcode) of the token,
  copy-link fallback, cancel; paste-link flow unchanged
- e2e: fake QR scanner consumes the real pairing token end-to-end
  (scan -> secret echo -> identity -> sign -> vault persistence)
This commit is contained in:
Avi 2026-09-12 04:47:20 -05:00
commit 38499d4506
12 changed files with 1041 additions and 49 deletions

View file

@ -1,11 +1,13 @@
//! End-to-end NIP-46 client test: the real `Nip46ClientSigner` connects
//! against a local relay and a fake Amber that speaks the bunker:// flow —
//! per-connection communication key, delayed human-approval ack, and a real
//! identity revealed only via `get_public_key`.
//! End-to-end NIP-46 client tests: the real `Nip46ClientSigner` runs against
//! a local relay and fake signers — a bunker:// Amber (per-connection comms
//! key, delayed human-approval ack) and a QR scanner that consumes a
//! client-minted `nostrconnect://` pairing token with secret verification.
//! Both reveal a real identity only via `get_public_key`.
//!
//! Exercises in one process: relay I/O, NIP-44 encryption, the
//! deferred-identity handshake, `sign_event` with full verification, and
//! vault persistence of the remote profile. No network, no phone.
//! Exercises in one process: relay I/O, NIP-44 encryption, both handshake
//! directions, the deferred-identity flow, `sign_event` with full
//! verification, and vault persistence of the remote profile.
//! No network, no phone.
use std::collections::HashMap;
use std::net::TcpListener as StdTcpListener;
@ -26,6 +28,10 @@ use serde_json::{json, Value};
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::Message;
/// Vault setup touches the process-global `XDG_DATA_HOME`; serialize it so
/// the two e2e tests in this binary cannot read each other's vault.
static VAULT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
// ---------------------------------------------------------------------------
// Minimal in-process nostr relay
// ---------------------------------------------------------------------------
@ -326,16 +332,20 @@ async fn run_fake_amber(relay_url: String, comms: Keys, identity: Keys, approval
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn nip46_client_handshake_and_sign_against_fake_amber() {
// Isolated vault so the test never touches the real user vault.
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
// XDG_DATA_HOME is process-global and both tests in this binary set it,
// so vault setup + App::load are serialized.
let app = {
let _guard = VAULT_ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
std::sync::Arc::new(Mutex::new(App::load().expect("load app")))
};
// Amber's keys: `comms` is the per-connection key in the bunker:// URI;
// `identity` is the REAL signing identity, never in the URI.
@ -448,3 +458,267 @@ async fn nip46_client_handshake_and_sign_against_fake_amber() {
signer.disconnect().await.ok();
let _ = PublicKey::from_hex; // keep import used across cfg variations
}
// ---------------------------------------------------------------------------
// QR pairing (client-initiated nostrconnect://): the fake signer plays the
// scanner role — it reads the pairing token the GUI would render, sends the
// `connect` request with the echoed secret, verifies the client's secret
// answer, then reveals its identity and signs like Amber.
// ---------------------------------------------------------------------------
async fn run_fake_scanner(
relay_url: String,
client_pk: PublicKey,
expected_secret: String,
identity: Keys,
) {
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
.await
.expect("scanner connect");
ws.send(Message::Text(
json!(["REQ", "scanner", {"kinds": [24133]}])
.to_string()
.into(),
))
.await
.unwrap();
let conversation = ConversationKey::derive(identity.secret_key(), &client_pk).unwrap();
// The scanned URI tells us who to contact and what secret to echo.
let connect_req = json!({
"id": "pair-1",
"method": "connect",
"params": [expected_secret.clone()],
});
let content = nip44_enc(&conversation, &connect_req.to_string());
let out = EventBuilder::new(Kind::NostrConnect, content)
.tags([Tag::parse(["p", client_pk.to_hex().as_str()]).unwrap()])
.finalize(&identity)
.unwrap();
ws.send(Message::Text(
json!([
"EVENT",
serde_json::from_str::<Value>(&out.as_json()).unwrap()
])
.to_string()
.into(),
))
.await
.unwrap();
while let Some(Ok(msg)) = ws.next().await {
let Message::Text(text) = msg else { continue };
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
continue;
};
if arr.first().and_then(|v| v.as_str()) != Some("EVENT") {
continue;
}
let Some(ev) = arr
.get(2)
.and_then(|v| v.as_object())
.and_then(|o| Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok())
else {
continue;
};
if ev.pubkey == identity.public_key() {
continue;
}
let Some(plain) = nip44_dec(&conversation, &ev.content) else {
continue;
};
let Ok(req) = serde_json::from_str::<Value>(&plain) else {
continue;
};
// The client's answer to our connect must carry the secret back —
// this is the anti-spoofing check the scanner performs in Amber.
if req.get("id").and_then(|v| v.as_str()) == Some("pair-1")
&& req.get("result").and_then(|v| v.as_str()) == Some(expected_secret.as_str())
{
// Secret echoed correctly — the check an actual scanner performs
// before approving. Nothing further to do with the ack itself.
continue;
}
let Some(method) = req.get("method").and_then(|m| m.as_str()) else {
continue;
};
let id = req
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let response: Value = match method {
"get_public_key" => json!({"id": id, "result": identity.public_key().to_hex()}),
"sign_event" => {
let unsigned_json = req["params"].get(0).and_then(|v| v.as_str());
match unsigned_json.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
Some(mut v) => {
if v.get("pubkey").is_none() {
v["pubkey"] = json!(identity.public_key().to_hex());
}
match serde_json::from_value::<UnsignedEvent>(v)
.ok()
.and_then(|u| identity.sign_event(u).ok())
{
Some(signed) => json!({"id": id, "result": signed.as_json()}),
None => json!({"id": id, "error": "sign failed"}),
}
}
None => json!({"id": id, "error": "bad params"}),
}
}
other => json!({"id": id, "error": format!("unsupported: {other}")}),
};
let content = nip44_enc(&conversation, &response.to_string());
let out = EventBuilder::new(Kind::NostrConnect, content)
.tags([Tag::parse(["p", client_pk.to_hex().as_str()]).unwrap()])
.finalize(&identity)
.unwrap();
ws.send(Message::Text(
json!([
"EVENT",
serde_json::from_str::<Value>(&out.as_json()).unwrap()
])
.to_string()
.into(),
))
.await
.unwrap();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn nip46_qr_pairing_handshake_and_sign() {
let relay_url = start_relay().await;
// Isolated vault + pairing relay, serialized with the other e2e test.
let app = {
let _guard = VAULT_ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-pair-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
std::sync::Arc::new(Mutex::new(App::load().expect("load app")))
};
{
let mut a = app.lock().await;
a.settings.relays = vec![keynectr::settings::RelayConfig::new(relay_url.clone())];
a.save_settings().expect("save settings");
}
let identity = Keys::generate();
let signer = Nip46ClientSigner::new(app.clone());
// Start pairing: we get back the token the GUI renders as a QR.
let status = signer
.start_pairing("qr pairing test".to_string())
.await
.expect("start pairing");
assert!(!status.connected, "not connected until someone scans");
let pairing_uri = status.pairing_uri.clone().expect("pairing URI present");
assert!(
pairing_uri.starts_with("nostrconnect://"),
"pairing token must be a nostrconnect:// URI"
);
// The token must be a well-formed client-initiated URI: ephemeral
// authority key, our relay, and the anti-spoofing secret.
let parsed = nostr::nips::nip46::NostrConnectUri::parse(&pairing_uri)
.expect("pairing URI parses with the same parser real signers use");
let nostr::nips::nip46::NostrConnectUri::Client {
public_key: client_pk,
secret,
relays,
..
} = parsed
else {
panic!("pairing URI must be the client variant");
};
assert_eq!(relays.len(), 1);
assert!(!secret.is_empty());
// The scanner (Amber role) consumes the token.
tokio::spawn(run_fake_scanner(
relay_url.clone(),
client_pk,
secret.clone(),
identity.clone(),
));
// Wait for scan -> secret echo -> identity adoption.
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
let status = signer.status().await;
if let Some(err) = &status.error {
panic!("pairing failed: {err}");
}
if status.connected {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"pairing never completed; last status: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
// The QR token is single-use: consumed, so it no longer appears.
assert!(
signer.status().await.pairing_uri.is_none(),
"pairing URI must be dropped once scanned"
);
// Identity came from get_public_key, not from the URI authority key.
let resolved = SignerTrait::get_public_key(&signer)
.await
.expect("identity resolved");
assert_eq!(resolved, identity.public_key());
assert_ne!(
resolved, client_pk,
"ephemeral pairing key must never become identity"
);
// Sign through the paired signer.
let unsigned = UnsignedEvent::new(
identity.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"paired via QR".to_string(),
);
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
.await
.expect("remote sign_event after pairing");
assert_eq!(signed.pubkey, identity.public_key());
assert_eq!(signed.content, "paired via QR");
assert!(signed.verify_signature());
// Vault persistence: remote profile under the real identity, no secrets.
let identity_npub = identity.public_key().to_bech32().unwrap();
let app_guard = app.lock().await;
let profile = app_guard
.vault
.profiles
.iter()
.find(|p| p.public_key == identity_npub)
.expect("remote profile row created by pairing");
assert_eq!(
profile.signer_mode,
keynectr::vault::SignerMode::Nip46Client
);
assert!(
profile.secret_key.trim().is_empty(),
"no secret material for remote profiles"
);
drop(app_guard);
signer.disconnect().await.ok();
}