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:
parent
764406e5a9
commit
38499d4506
12 changed files with 1041 additions and 49 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue