One live NIP-46 session, many saved ones (Option A): - start_pairing/connect while a session is live PARKS it instead of refusing: row, pairing secret, and persisted client key stay intact, so the parked account is restorable with no fresh scan. - SelectProfile follows the switch: target has a restorable connection -> park current + re-dial target's row (expected_identity guard applies); target is local-key or unpaired -> live session untouched. - New nip46_cancel_pairing IPC: aborts ONLY an in-flight pairing and re-dials the parked session, so cancel-after-park is transparent. The QR cancel paths (Add-profile modal, Signer Mode screen) use it — plain disconnect would revoke the parked connection. - e2e: two fake Ambers on one relay; A pairs, B's pairing parks A (revoked_at none, client key resolvable), switch back re-dials A and signs; no-op switch; local profile leaves session alone; B restorable.
1571 lines
60 KiB
Rust
1571 lines
60 KiB
Rust
//! 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, 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;
|
|
use std::time::Duration;
|
|
|
|
use base64::engine::general_purpose::STANDARD as B64;
|
|
use base64::Engine;
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use keynectr::app::App;
|
|
use keynectr::signer::nip46_client::Nip46ClientSigner;
|
|
use keynectr::signer::Signer as SignerTrait;
|
|
use keynectr::vault::Vault;
|
|
use nostr::nips::nip19::ToBech32;
|
|
use nostr::nips::nip44::v2;
|
|
use nostr::nips::nip44::v2::ConversationKey;
|
|
use nostr_sdk::prelude::*;
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct Session {
|
|
tx: mpsc::UnboundedSender<String>,
|
|
subs: HashMap<String, Vec<Value>>,
|
|
}
|
|
|
|
struct RelayState {
|
|
sessions: Vec<Session>,
|
|
events: Vec<Event>,
|
|
}
|
|
|
|
/// Match a stored/incoming event against a REQ filter (subset of the nostr
|
|
/// relay spec sufficient for this test: kinds + authors).
|
|
fn matches(filter: &Value, ev: &Event) -> bool {
|
|
if let Some(kinds) = filter.get("kinds").and_then(|k| k.as_array()) {
|
|
if !kinds
|
|
.iter()
|
|
.any(|k| k.as_u64() == Some(u64::from(u16::from(ev.kind))))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
if let Some(authors) = filter.get("authors").and_then(|a| a.as_array()) {
|
|
if !authors.is_empty()
|
|
&& !authors
|
|
.iter()
|
|
.any(|a| a.as_str() == Some(ev.pubkey.to_hex().as_str()))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
async fn start_relay() -> String {
|
|
let std_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind relay");
|
|
std_listener.set_nonblocking(true).expect("nonblocking");
|
|
let listener = tokio::net::TcpListener::from_std(std_listener).expect("tokio listener");
|
|
let port = listener.local_addr().unwrap().port();
|
|
let url = format!("ws://127.0.0.1:{port}");
|
|
let state: std::sync::Arc<Mutex<RelayState>> = std::sync::Arc::new(Mutex::new(RelayState {
|
|
sessions: Vec::new(),
|
|
events: Vec::new(),
|
|
}));
|
|
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let Ok((stream, _)) = listener.accept().await else {
|
|
continue;
|
|
};
|
|
let Ok(socket) = tokio_tungstenite::accept_async(stream).await else {
|
|
continue;
|
|
};
|
|
let state = state.clone();
|
|
tokio::spawn(async move {
|
|
let (mut write, mut read) = socket.split();
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
|
let session_idx = {
|
|
let mut s = state.lock().await;
|
|
s.sessions.push(Session {
|
|
tx,
|
|
subs: HashMap::new(),
|
|
});
|
|
s.sessions.len() - 1
|
|
};
|
|
|
|
// Writer half.
|
|
let writer = tokio::spawn(async move {
|
|
while let Some(line) = rx.recv().await {
|
|
if write.send(Message::Text(line.into())).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
while let Some(Ok(msg)) = read.next().await {
|
|
let Message::Text(text) = msg else { continue };
|
|
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
|
|
continue;
|
|
};
|
|
match arr.first().and_then(|v| v.as_str()).unwrap_or("") {
|
|
"REQ" => {
|
|
let Some(sub_id) = arr.get(1).and_then(|v| v.as_str()) else {
|
|
continue;
|
|
};
|
|
let filters: Vec<Value> = arr[2..].to_vec();
|
|
let mut s = state.lock().await;
|
|
if let Some(sess) = s.sessions.get_mut(session_idx) {
|
|
sess.subs.insert(sub_id.to_string(), filters.clone());
|
|
}
|
|
// Replay matching stored events so late joiners
|
|
// never miss messages they raced past.
|
|
for ev in &s.events {
|
|
for f in &filters {
|
|
if matches(f, ev) {
|
|
let out = json!([
|
|
"EVENT",
|
|
sub_id,
|
|
serde_json::from_str::<Value>(&ev.as_json())
|
|
.unwrap_or_default()
|
|
])
|
|
.to_string();
|
|
if let Some(sess) = s.sessions.get(session_idx) {
|
|
eprintln!(
|
|
"[relay] replay {} to new sub {}",
|
|
&ev.id.to_hex()[..16],
|
|
sub_id
|
|
);
|
|
let _ = sess.tx.send(out);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let eose = json!(["EOSE", sub_id]).to_string();
|
|
if let Some(sess) = s.sessions.get(session_idx) {
|
|
let _ = sess.tx.send(eose);
|
|
}
|
|
}
|
|
"CLOSE" => {
|
|
if let Some(sub_id) = arr.get(1).and_then(|v| v.as_str()) {
|
|
let mut s = state.lock().await;
|
|
if let Some(sess) = s.sessions.get_mut(session_idx) {
|
|
sess.subs.remove(sub_id);
|
|
}
|
|
}
|
|
}
|
|
"EVENT" => {
|
|
let Some(ev) = arr.get(1).and_then(|v| v.as_object()).and_then(|o| {
|
|
Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok()
|
|
}) else {
|
|
continue;
|
|
};
|
|
let mut s = state.lock().await;
|
|
s.events.push(ev.clone());
|
|
// OK notice: nostr-sdk's send_event waits for the
|
|
// relay to accept the event before resolving.
|
|
let ok = json!(["OK", ev.id.to_hex(), true, ""]).to_string();
|
|
if let Some(sess) = s.sessions.get(session_idx) {
|
|
let _ = sess.tx.send(ok);
|
|
}
|
|
let ev_json =
|
|
serde_json::from_str::<Value>(&ev.as_json()).unwrap_or_default();
|
|
// Broadcast to every OTHER session with a
|
|
// matching subscription (relay spec: no echo to
|
|
// origin).
|
|
for (idx, sess) in s.sessions.iter().enumerate() {
|
|
if idx == session_idx {
|
|
continue;
|
|
}
|
|
for (sub_id, filters) in &sess.subs {
|
|
if filters.iter().any(|f| matches(f, &ev)) {
|
|
let out = json!(["EVENT", sub_id, ev_json]).to_string();
|
|
let _ = sess.tx.send(out.clone());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
writer.abort();
|
|
let mut s = state.lock().await;
|
|
if let Some(sess) = s.sessions.get_mut(session_idx) {
|
|
// Leave a dead session slot; harmless for a test relay.
|
|
sess.subs.clear();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
url
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake Amber: signer role with a per-connection comms key + real identity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn nip44_enc(conversation: &ConversationKey, plaintext: &str) -> String {
|
|
let mut nonce = [0u8; 32];
|
|
getrandom::getrandom(&mut nonce).unwrap();
|
|
let bytes = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce).unwrap();
|
|
B64.encode(bytes)
|
|
}
|
|
|
|
fn nip44_dec(conversation: &ConversationKey, content: &str) -> Option<String> {
|
|
let bytes = B64.decode(content).ok()?;
|
|
let plain = v2::decrypt_to_bytes(conversation, &bytes).ok()?;
|
|
String::from_utf8(plain).ok()
|
|
}
|
|
|
|
/// Connect to the relay as Amber: subscribe to kind 24133, answer the
|
|
/// bunker:// handshake (simulated human approval delay), reveal the real
|
|
/// identity key, and sign events with it.
|
|
async fn run_fake_amber(relay_url: String, comms: Keys, identity: Keys, approval_delay: Duration) {
|
|
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
|
|
.await
|
|
.expect("amber connect");
|
|
ws.send(Message::Text(
|
|
json!(["REQ", "amber", {"kinds": [24133]}])
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let comms_pub = comms.public_key();
|
|
|
|
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;
|
|
};
|
|
// Never answer our own messages.
|
|
if ev.pubkey == comms_pub {
|
|
continue;
|
|
}
|
|
// Try to decrypt with a conversation keyed to this sender. A failure
|
|
// means the message was not addressed to us.
|
|
let Ok(conversation) = ConversationKey::derive(comms.secret_key(), &ev.pubkey) else {
|
|
continue;
|
|
};
|
|
let Some(plain) = nip44_dec(&conversation, &ev.content) else {
|
|
continue;
|
|
};
|
|
let Ok(req) = serde_json::from_str::<Value>(&plain) else {
|
|
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 {
|
|
"connect" => {
|
|
// Simulate a human tapping "approve" in Amber. Amber's
|
|
// ack SHAPE depends on the connection state: a first-time
|
|
// pairing (no secret in params) gets a plain ack; an
|
|
// ALREADY-APPROVED connection re-dialing with the stored
|
|
// secret gets `true` WITHOUT a secret echo (live Amber,
|
|
// Sep 25 — the client must not demand an echo there).
|
|
tokio::time::sleep(approval_delay).await;
|
|
if req["params"].as_array().is_some_and(|p| p.len() > 1) {
|
|
json!({"id": id, "result": true})
|
|
} else {
|
|
json!({"id": id, "result": "ack"})
|
|
}
|
|
}
|
|
"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", ev.pubkey.to_hex().as_str()]).unwrap()])
|
|
.finalize(&comms)
|
|
.unwrap();
|
|
ws.send(Message::Text(
|
|
json!([
|
|
"EVENT",
|
|
serde_json::from_str::<Value>(&out.as_json()).unwrap()
|
|
])
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
|
|
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
|
|
/// test-only serialization lock, never nested, never shared with production
|
|
/// code — holding it across awaits is the entire point.
|
|
#[allow(clippy::await_holding_lock)]
|
|
async fn nip46_client_handshake_and_sign_against_fake_amber() {
|
|
// Isolated vault so the test never touches the real user vault.
|
|
// XDG_DATA_HOME is process-global and every test in this binary sets it,
|
|
// so the lock is held for the WHOLE test: data_dir() re-reads the env on
|
|
// every save, and a setup-only guard lets parallel tests cross-write.
|
|
let _vault_guard = VAULT_ENV_LOCK
|
|
.lock()
|
|
// Poison-tolerant on purpose: this lock only serializes the
|
|
// process-global XDG_DATA_HOME. A sibling test that panics while
|
|
// holding it must not cascade into PoisonError failures of every
|
|
// later test — one real failure should report as ONE failure.
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let app = {
|
|
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id()));
|
|
// data_dir() is $XDG_DATA_HOME/keynectr — the isolation vault must
|
|
// live there. Writing it one level too shallow left the app finding
|
|
// NO vault at the real path, which silently migrated the legacy
|
|
// repo vault (with the user's real keys!) into the test instead.
|
|
let app_dir = tmp.join("keynectr");
|
|
std::fs::create_dir_all(&app_dir).unwrap();
|
|
std::fs::write(
|
|
app_dir.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")));
|
|
// Guard: if seeding ever misses the real data_dir path again,
|
|
// load_vault() silently migrates the legacy repo vault (real user
|
|
// keys) into the test. An isolated run must load an EMPTY vault.
|
|
assert!(
|
|
app.try_lock().unwrap().vault.profiles.is_empty(),
|
|
"e2e vault isolation failed: a non-empty vault was loaded — \
|
|
the seeded vault is not where data_dir() looks"
|
|
);
|
|
app
|
|
};
|
|
|
|
// Amber's keys: `comms` is the per-connection key in the bunker:// URI;
|
|
// `identity` is the REAL signing identity, never in the URI.
|
|
let comms = Keys::generate();
|
|
let identity = Keys::generate();
|
|
|
|
let relay_url = start_relay().await;
|
|
tokio::spawn(run_fake_amber(
|
|
relay_url.clone(),
|
|
comms.clone(),
|
|
identity.clone(),
|
|
Duration::from_millis(400),
|
|
));
|
|
|
|
let signer = Nip46ClientSigner::new(app.clone());
|
|
|
|
// Fail closed: signing before connect must error, never fall back.
|
|
let unsigned = UnsignedEvent::new(
|
|
identity.public_key(),
|
|
Timestamp::now(),
|
|
Kind::TextNote,
|
|
vec![],
|
|
"hello via amber".to_string(),
|
|
);
|
|
assert!(
|
|
SignerTrait::sign_event(&signer, unsigned.clone())
|
|
.await
|
|
.is_err(),
|
|
"signing before connect must fail closed"
|
|
);
|
|
|
|
// Amber shows exactly this URI: authority = comms key, no identity.
|
|
let uri = format!(
|
|
"bunker://{}?relay={}",
|
|
comms.public_key().to_hex(),
|
|
relay_url
|
|
);
|
|
let status = signer
|
|
.connect(&uri, "fake amber".to_string())
|
|
.await
|
|
.expect("connect");
|
|
// Session starts Connecting, not Connected: identity is not yet proven.
|
|
assert!(!status.connected, "must not be connected before handshake");
|
|
|
|
// Wait for the handshake (approval delay + get_public_key) to complete.
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
let status = signer.status().await;
|
|
if let Some(err) = &status.error {
|
|
panic!("signer failed: {err}");
|
|
}
|
|
if status.connected {
|
|
break;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"handshake never completed; last status: {:?}",
|
|
signer.status().await
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
|
|
// Identity must be the REAL key, not the URI comms key.
|
|
let resolved = SignerTrait::get_public_key(&signer)
|
|
.await
|
|
.expect("identity resolved");
|
|
assert_eq!(
|
|
resolved,
|
|
identity.public_key(),
|
|
"identity must come from get_public_key"
|
|
);
|
|
assert_ne!(
|
|
resolved,
|
|
comms.public_key(),
|
|
"URI key must never become identity"
|
|
);
|
|
|
|
// Sign a note through the external signer and verify the client checks
|
|
// identity, id, and signature on the returned event.
|
|
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
|
|
.await
|
|
.expect("remote sign_event");
|
|
assert_eq!(signed.pubkey, identity.public_key());
|
|
assert_eq!(signed.content, "hello via amber");
|
|
assert_eq!(signed.id, unsigned.compute_id());
|
|
assert!(signed.verify_signature());
|
|
|
|
// Vault persistence: the handshake stored a remote profile under the
|
|
// REAL identity, in external-signer mode.
|
|
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");
|
|
assert_eq!(
|
|
profile.signer_mode,
|
|
keynectr::vault::SignerMode::Nip46Client,
|
|
"remote profile must be in external-signer mode"
|
|
);
|
|
assert!(
|
|
profile.secret_key.trim().is_empty(),
|
|
"no secret material for remote profiles"
|
|
);
|
|
drop(app_guard);
|
|
|
|
// Clean teardown so a failed run cannot leave a stuck task.
|
|
signer.disconnect().await.ok();
|
|
let _ = PublicKey::from_hex; // keep import used across cfg variations
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Strict Amber for the bunker:// (paste-URI) flow: validates the `connect`
|
|
// request against NIP-46 instead of acking anything. params[0] MUST be the
|
|
// remote signer's pubkey (the URI authority) — the client's own pubkey there
|
|
// is a spec violation that real signers answer with silence, stalling the
|
|
// handshake with zero feedback. This test FAILS on the old param order and
|
|
// passes on the fixed one.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Run Amber in strict mode: enforce the NIP-46 `connect` shape, then behave
|
|
/// like the lenient fake (delayed approval ack, real identity, remote sign).
|
|
async fn run_strict_amber(
|
|
relay_url: String,
|
|
comms: Keys,
|
|
identity: Keys,
|
|
approval_delay: Duration,
|
|
) {
|
|
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
|
|
.await
|
|
.expect("strict amber connect");
|
|
ws.send(Message::Text(
|
|
json!(["REQ", "strict-amber", {"kinds": [24133]}])
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let comms_pub = comms.public_key();
|
|
let comms_hex = comms_pub.to_hex();
|
|
|
|
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 == comms_pub {
|
|
continue;
|
|
}
|
|
let Ok(conversation) = ConversationKey::derive(comms.secret_key(), &ev.pubkey) else {
|
|
continue;
|
|
};
|
|
let Some(plain) = nip44_dec(&conversation, &ev.content) else {
|
|
continue;
|
|
};
|
|
let Ok(req) = serde_json::from_str::<Value>(&plain) else {
|
|
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 {
|
|
"connect" => {
|
|
let first_param = req
|
|
.get("params")
|
|
.and_then(|p| p.get(0))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
if first_param != comms_hex {
|
|
json!({"id": id, "error": "connect params must start with the remote signer pubkey"})
|
|
} else {
|
|
tokio::time::sleep(approval_delay).await;
|
|
json!({"id": id, "result": "ack"})
|
|
}
|
|
}
|
|
"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", ev.pubkey.to_hex().as_str()]).unwrap()])
|
|
.finalize(&comms)
|
|
.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)]
|
|
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
|
|
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
|
|
/// test-only serialization lock, never nested, never shared with production
|
|
/// code — holding it across awaits is the entire point.
|
|
#[allow(clippy::await_holding_lock)]
|
|
async fn nip46_bunker_connect_params_match_spec_against_strict_amber() {
|
|
// Whole-body env lock: data_dir() re-reads XDG_DATA_HOME on every save.
|
|
let _vault_guard = VAULT_ENV_LOCK
|
|
.lock()
|
|
// Poison-tolerant on purpose: this lock only serializes the
|
|
// process-global XDG_DATA_HOME. A sibling test that panics while
|
|
// holding it must not cascade into PoisonError failures of every
|
|
// later test — one real failure should report as ONE failure.
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let app = {
|
|
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-strict-{}", std::process::id()));
|
|
let app_dir = tmp.join("keynectr");
|
|
std::fs::create_dir_all(&app_dir).unwrap();
|
|
std::fs::write(
|
|
app_dir.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")));
|
|
assert!(
|
|
app.try_lock().unwrap().vault.profiles.is_empty(),
|
|
"e2e vault isolation failed: a non-empty vault was loaded"
|
|
);
|
|
app
|
|
};
|
|
|
|
// `comms` is the per-connection key in the bunker:// URI; `identity` is
|
|
// the REAL signing identity, never in the URI.
|
|
let comms = Keys::generate();
|
|
let identity = Keys::generate();
|
|
|
|
let relay_url = start_relay().await;
|
|
tokio::spawn(run_strict_amber(
|
|
relay_url.clone(),
|
|
comms.clone(),
|
|
identity.clone(),
|
|
Duration::from_millis(200),
|
|
));
|
|
|
|
let signer = Nip46ClientSigner::new(app.clone());
|
|
// Register the handle on the App exactly like production's
|
|
// `ensure_nip46_signer` does: `App::signing_for` (used below for the
|
|
// kind-0 publish) resolves the live session through this handle.
|
|
// `Nip46ClientSigner::clone` shares the session state.
|
|
app.lock().await.nip46_signer = Some(std::sync::Arc::new(signer.clone()));
|
|
|
|
// No secret in the URI: the strict signer must still ack a well-formed
|
|
// connect whose params[0] is its own pubkey.
|
|
let uri = format!(
|
|
"bunker://{}?relay={}",
|
|
comms.public_key().to_hex(),
|
|
relay_url
|
|
);
|
|
let status = signer
|
|
.connect(&uri, "strict amber".to_string())
|
|
.await
|
|
.expect("connect");
|
|
assert!(!status.connected, "must not be connected before handshake");
|
|
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
|
|
loop {
|
|
let status = signer.status().await;
|
|
if let Some(err) = &status.error {
|
|
panic!("strict handshake failed: {err}");
|
|
}
|
|
if status.connected {
|
|
break;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"strict handshake never completed; last status: {:?}",
|
|
signer.status().await
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
|
|
// Identity must be the REAL key from get_public_key — the actual user
|
|
// pubkey the account manager stores — never the URI comms key and never
|
|
// the client's own ephemeral key.
|
|
let resolved = SignerTrait::get_public_key(&signer)
|
|
.await
|
|
.expect("identity resolved");
|
|
assert_eq!(resolved, identity.public_key());
|
|
assert_ne!(resolved, comms.public_key());
|
|
|
|
let identity_npub = identity.public_key().to_bech32().unwrap();
|
|
let app_guard = app.lock().await;
|
|
assert!(
|
|
app_guard
|
|
.vault
|
|
.profiles
|
|
.iter()
|
|
.any(|p| p.public_key == identity_npub && p.secret_key.trim().is_empty()),
|
|
"remote profile row for the real identity must be stored with no secret"
|
|
);
|
|
assert_eq!(
|
|
app_guard.vault.active_profile.as_deref(),
|
|
Some(identity_npub.as_str()),
|
|
"the connected account must become the active profile"
|
|
);
|
|
drop(app_guard);
|
|
|
|
// Kind-0 through the remote signer: the vault label becomes a signed
|
|
// network-visible profile (what other clients display as the name).
|
|
// Exercises the real GUI "Publish name" path — Signing::External with
|
|
// identity validation — against the strict signer.
|
|
// Point settings at the in-process relay FIRST: the default relay set is
|
|
// the real internet (damus + nostr.band, the latter a known-hanger), so
|
|
// leaving it made this assertion a network lottery — it failed whenever
|
|
// damus dawdled past the 6s send timeout. The QR test already does this;
|
|
// the strict test shipped without it.
|
|
{
|
|
let mut a = app.lock().await;
|
|
a.settings.relays = vec![keynectr::settings::RelayConfig::new(relay_url.clone())];
|
|
a.save_settings().expect("save settings");
|
|
}
|
|
let (settings, signing) = {
|
|
let guard = app.lock().await;
|
|
(
|
|
guard.settings.clone(),
|
|
guard
|
|
.signing_for(&identity_npub)
|
|
.await
|
|
.expect("signing source for the paired profile"),
|
|
)
|
|
};
|
|
let report = keynectr::profiles::publish_metadata_signed(
|
|
&settings,
|
|
"Strict Amber",
|
|
None,
|
|
None,
|
|
&signing,
|
|
)
|
|
.await
|
|
.expect("remote kind-0 publish");
|
|
assert!(
|
|
!report.succeeded.is_empty(),
|
|
"at least one relay must accept the signed kind-0"
|
|
);
|
|
|
|
signer.disconnect().await.ok();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
connect_shape: &'static str,
|
|
) {
|
|
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.
|
|
// Two shapes:
|
|
// - "request": {"id","method":"connect","params":[secret]} — what the
|
|
// e2e originally simulated; the client answers with the secret.
|
|
// - "response": {"id","result":secret} — what NIP-46 actually specifies
|
|
// for nostrconnect:// ("the _remote-signer_ … sends `connect`
|
|
// *response* event"), and what Amber sends. No answer expected.
|
|
let connect_msg = match connect_shape {
|
|
"response" => json!({ "id": "pair-1", "result": expected_secret.clone() }),
|
|
_ => json!({
|
|
"id": "pair-1",
|
|
"method": "connect",
|
|
"params": [expected_secret.clone()],
|
|
}),
|
|
};
|
|
let content = nip44_enc(&conversation, &connect_msg.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 {
|
|
eprintln!(
|
|
"[scanner] event from {} not decryptable",
|
|
&ev.pubkey.to_hex()[..16]
|
|
);
|
|
continue;
|
|
};
|
|
eprintln!("[scanner] decrypted: {}", &plain[..plain.len().min(120)]);
|
|
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)]
|
|
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
|
|
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
|
|
/// test-only serialization lock, never nested, never shared with production
|
|
/// code — holding it across awaits is the entire point.
|
|
#[allow(clippy::await_holding_lock)]
|
|
async fn nip46_qr_pairing_handshake_and_sign() {
|
|
run_qr_pairing("request").await;
|
|
}
|
|
|
|
/// The shape NIP-46 actually specifies for a `nostrconnect://` scan — and
|
|
/// what Amber sends — is a connect *response* (`{"id","result":"<secret>"}`),
|
|
/// not a `connect` request. Pairing must complete on that shape too.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
|
|
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
|
|
/// test-only serialization lock, never nested, never shared with production
|
|
/// code — holding it across awaits is the entire point.
|
|
#[allow(clippy::await_holding_lock)]
|
|
async fn nip46_qr_pairing_connect_response_shape() {
|
|
run_qr_pairing("response").await;
|
|
}
|
|
|
|
/// Whole-body env lock like the test fns above (test-only, never nested).
|
|
#[allow(clippy::await_holding_lock)]
|
|
async fn run_qr_pairing(connect_shape: &'static str) {
|
|
let relay_url = start_relay().await;
|
|
|
|
// Isolated vault + pairing relay; the env lock is held for the whole
|
|
// helper body (see above) so parallel tests cannot cross-write vaults.
|
|
let _vault_guard = VAULT_ENV_LOCK
|
|
.lock()
|
|
// Poison-tolerant on purpose: this lock only serializes the
|
|
// process-global XDG_DATA_HOME. A sibling test that panics while
|
|
// holding it must not cascade into PoisonError failures of every
|
|
// later test — one real failure should report as ONE failure.
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let app = {
|
|
let tmp = std::env::temp_dir().join(format!(
|
|
"keynectr-e2e-pair-{}-{}",
|
|
std::process::id(),
|
|
connect_shape
|
|
));
|
|
// Must be $XDG_DATA_HOME/keynectr/profiles_vault.json (see the
|
|
// sibling test above): the old shallow path let every e2e run
|
|
// migrate the real legacy repo vault into the test process.
|
|
let app_dir = tmp.join("keynectr");
|
|
std::fs::create_dir_all(&app_dir).unwrap();
|
|
std::fs::write(
|
|
app_dir.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")));
|
|
// Same empty-vault guard as the sibling test above.
|
|
assert!(
|
|
app.try_lock().unwrap().vault.profiles.is_empty(),
|
|
"e2e vault isolation failed: a non-empty vault was loaded — \
|
|
the seeded vault is not where data_dir() looks"
|
|
);
|
|
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");
|
|
};
|
|
// The e2e relay set is loopback-only, and loopback sets skip the curated
|
|
// pairing-relay widening, so the token carries exactly our relay.
|
|
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(),
|
|
connect_shape,
|
|
));
|
|
|
|
// 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();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session restore (re-dial without a scan): Amber remembers our CLIENT
|
|
// pubkey for the life of a connection, so a restart must reuse the exact
|
|
// keypair persisted at pairing, re-send `connect`, and refuse the session
|
|
// if the signer answers as a different account.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[allow(clippy::await_holding_lock)]
|
|
async fn nip46_session_restore_redials_and_refuses_wrong_identity() {
|
|
let _vault_guard = VAULT_ENV_LOCK
|
|
.lock()
|
|
// Poison-tolerant on purpose: this lock only serializes the
|
|
// process-global XDG_DATA_HOME. A sibling test that panics while
|
|
// holding it must not cascade into PoisonError failures of every
|
|
// later test — one real failure should report as ONE failure.
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
let app = {
|
|
let tmp = std::env::temp_dir().join(format!(
|
|
"keynectr-e2e-restore-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let app_dir = tmp.join("keynectr");
|
|
std::fs::create_dir_all(&app_dir).unwrap();
|
|
std::fs::write(
|
|
app_dir.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")));
|
|
assert!(
|
|
app.try_lock().unwrap().vault.profiles.is_empty(),
|
|
"e2e vault isolation failed for restore test"
|
|
);
|
|
app
|
|
};
|
|
|
|
let comms = Keys::generate();
|
|
let identity = Keys::generate();
|
|
let relay_url = start_relay().await;
|
|
tokio::spawn(run_fake_amber(
|
|
relay_url.clone(),
|
|
comms.clone(),
|
|
identity.clone(),
|
|
Duration::from_millis(50),
|
|
));
|
|
|
|
// --- 1. Fresh pairing: the flow that must later NOT need repeating.
|
|
let signer = Nip46ClientSigner::new(app.clone());
|
|
let uri = format!(
|
|
"bunker://{}?relay={}",
|
|
comms.public_key().to_hex(),
|
|
relay_url
|
|
);
|
|
signer
|
|
.connect(&uri, "fake amber".to_string())
|
|
.await
|
|
.expect("fresh connect");
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
let st = signer.status().await;
|
|
if let Some(err) = &st.error {
|
|
panic!("fresh pairing failed: {err}");
|
|
}
|
|
if st.connected {
|
|
break;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"fresh pairing never connected: {:?}",
|
|
signer.status().await
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
|
|
// Pairing must have persisted OUR client secret key, re-keyed under the
|
|
// identity-keyed VaultRef (that is what a re-dial resolves).
|
|
let identity_npub = identity.public_key().to_bech32().unwrap();
|
|
let ref_id =
|
|
keynectr::signer::VaultRef::new(Some(identity_npub.clone()), comms.public_key().to_hex());
|
|
let client_key_hex = {
|
|
let g = app.lock().await;
|
|
let ck = keynectr::vault::resolve_connection_client_key(&g.vault, None, &ref_id)
|
|
.expect("resolve client key")
|
|
.expect("client secret key must be persisted at pairing");
|
|
ck.to_string()
|
|
};
|
|
|
|
// --- 2. Simulated app restart: a NEW signer instance over the same
|
|
// vault must re-dial the saved session with no scan and no bunker URI.
|
|
// (The old instance's listener task is left running on purpose — the
|
|
// live process exiting is modeled by the new instance, not by
|
|
// `disconnect()`, which revokes and wipes the stored key.)
|
|
//
|
|
// Seed a pairing secret at the identity ref first: a QR pairing stores
|
|
// one, and the restore re-sends it — real Amber then answers `true`
|
|
// WITHOUT echoing (already-approved connection), which the fake models.
|
|
// Without the restore-mode skip this handshake fails "did not echo the
|
|
// connection secret" (the exact live Sep 25 failure).
|
|
{
|
|
let mut g = app.lock().await;
|
|
keynectr::vault::store_connection_secret(&mut g.vault, None, &ref_id, "restore-secret-123")
|
|
.unwrap();
|
|
g.save_vault().unwrap();
|
|
}
|
|
let signer2 = Nip46ClientSigner::new(app.clone());
|
|
let restored = signer2
|
|
.reactivate_saved_sessions()
|
|
.await
|
|
.expect("restore call");
|
|
assert_eq!(restored, 1, "one saved session must be restorable");
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
let st = signer2.status().await;
|
|
if let Some(err) = &st.error {
|
|
panic!("restored session failed: {err}");
|
|
}
|
|
if st.connected {
|
|
break;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"restored session never connected: {:?}",
|
|
signer2.status().await
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
let resolved = SignerTrait::get_public_key(&signer2)
|
|
.await
|
|
.expect("identity on restored session");
|
|
assert_eq!(
|
|
resolved,
|
|
identity.public_key(),
|
|
"restored session must bind the ORIGINAL identity"
|
|
);
|
|
|
|
// The restored session is fully usable: remote sign_event verifies.
|
|
let unsigned = UnsignedEvent::new(
|
|
identity.public_key(),
|
|
Timestamp::now(),
|
|
Kind::TextNote,
|
|
vec![],
|
|
"signed after restart".to_string(),
|
|
);
|
|
let signed = SignerTrait::sign_event(&signer2, unsigned.clone())
|
|
.await
|
|
.expect("sign through restored session");
|
|
assert_eq!(signed.pubkey, identity.public_key());
|
|
assert_eq!(signed.content, "signed after restart");
|
|
assert_eq!(signed.id, unsigned.compute_id());
|
|
assert!(signed.verify_signature());
|
|
|
|
// --- 3. Legacy skip: a connection with no stored client key (paired
|
|
// before key persistence existed) is NOT re-dialed — one fresh scan is
|
|
// required for those.
|
|
{
|
|
let mut g = app.lock().await;
|
|
keynectr::vault::delete_connection_client_key(&mut g.vault, &ref_id);
|
|
g.save_vault().unwrap();
|
|
}
|
|
let signer3 = Nip46ClientSigner::new(app.clone());
|
|
assert_eq!(
|
|
signer3
|
|
.reactivate_saved_sessions()
|
|
.await
|
|
.expect("legacy restore call"),
|
|
0,
|
|
"keyless legacy connection must be skipped"
|
|
);
|
|
|
|
// --- 4. Cross-account guard: put the client key under a DIFFERENT
|
|
// profile's ref (as if profile B reused this Amber connection) and move
|
|
// the connection row to that profile. The re-dial dials fine, but the
|
|
// fake Amber still answers as the ORIGINAL identity — the identity
|
|
// check must refuse the session outright, never adopt it.
|
|
let impostor = Keys::generate();
|
|
let impostor_npub = impostor.public_key().to_bech32().unwrap();
|
|
{
|
|
let mut g = app.lock().await;
|
|
let ref_b = keynectr::signer::VaultRef::new(
|
|
Some(impostor_npub.clone()),
|
|
comms.public_key().to_hex(),
|
|
);
|
|
keynectr::vault::store_connection_client_key(&mut g.vault, None, &ref_b, &client_key_hex)
|
|
.unwrap();
|
|
g.vault.nip46_connections[0].profile_npub = Some(impostor_npub.clone());
|
|
g.save_vault().unwrap();
|
|
}
|
|
let signer4 = Nip46ClientSigner::new(app.clone());
|
|
assert_eq!(
|
|
signer4
|
|
.reactivate_saved_sessions()
|
|
.await
|
|
.expect("cross-account restore call"),
|
|
1,
|
|
"the re-dial itself must start; refusal happens in the handshake"
|
|
);
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
let st = signer4.status().await;
|
|
if let Some(err) = &st.error {
|
|
assert!(
|
|
err.contains("different account"),
|
|
"cross-account restore failed for the wrong reason: {err}"
|
|
);
|
|
break;
|
|
}
|
|
assert!(
|
|
!st.connected,
|
|
"a restored session answering as the wrong account must NEVER connect"
|
|
);
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"cross-account restore never failed: {:?}",
|
|
signer4.status().await
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Option A: many saved sessions, one live. Pairing/connecting a second
|
|
// signer account PARKS the live session (restorable, never revoked), and
|
|
// switching a profile back to a parked account re-dials it with no scan.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[allow(clippy::await_holding_lock)]
|
|
async fn nip46_second_account_parks_first_and_switch_restores_it() {
|
|
let _vault_guard = VAULT_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
let app = {
|
|
let tmp = std::env::temp_dir().join(format!(
|
|
"keynectr-e2e-switch-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let app_dir = tmp.join("keynectr");
|
|
std::fs::create_dir_all(&app_dir).unwrap();
|
|
std::fs::write(
|
|
app_dir.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")));
|
|
assert!(
|
|
app.try_lock().unwrap().vault.profiles.is_empty(),
|
|
"e2e vault isolation failed for switch test"
|
|
);
|
|
app
|
|
};
|
|
|
|
let relay_url = start_relay().await;
|
|
let comms_a = Keys::generate();
|
|
let identity_a = Keys::generate();
|
|
let comms_b = Keys::generate();
|
|
let identity_b = Keys::generate();
|
|
// Two fake Ambers on one relay: each only answers traffic it can
|
|
// NIP-44-decrypt with its own comms key, so they never cross-talk.
|
|
tokio::spawn(run_fake_amber(
|
|
relay_url.clone(),
|
|
comms_a.clone(),
|
|
identity_a.clone(),
|
|
Duration::from_millis(30),
|
|
));
|
|
tokio::spawn(run_fake_amber(
|
|
relay_url.clone(),
|
|
comms_b.clone(),
|
|
identity_b.clone(),
|
|
Duration::from_millis(30),
|
|
));
|
|
|
|
let signer = Nip46ClientSigner::new(app.clone());
|
|
|
|
let wait_connected = |signer: Nip46ClientSigner| async move {
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
|
loop {
|
|
let st = signer.status().await;
|
|
if let Some(err) = &st.error {
|
|
panic!("session failed: {err}");
|
|
}
|
|
if st.connected {
|
|
return;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"session never connected: {:?}",
|
|
signer.status().await
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
};
|
|
|
|
// --- 1. Account A pairs (the flow the GUI runs).
|
|
signer
|
|
.connect(
|
|
&format!(
|
|
"bunker://{}?relay={}",
|
|
comms_a.public_key().to_hex(),
|
|
relay_url
|
|
),
|
|
"amber A".to_string(),
|
|
)
|
|
.await
|
|
.expect("connect account A");
|
|
wait_connected(signer.clone()).await;
|
|
let npub_a = SignerTrait::get_public_key(&signer)
|
|
.await
|
|
.expect("identity A")
|
|
.to_bech32()
|
|
.unwrap();
|
|
|
|
// --- 2. Account B pairs while A is live. The IPC dispatcher parks the
|
|
// live session first (the exact sequence the dispatcher now runs).
|
|
assert!(signer.has_live_session().await);
|
|
signer.park_live_session().await;
|
|
assert!(
|
|
!signer.has_live_session().await,
|
|
"park must clear the live slot"
|
|
);
|
|
signer
|
|
.connect(
|
|
&format!(
|
|
"bunker://{}?relay={}",
|
|
comms_b.public_key().to_hex(),
|
|
relay_url
|
|
),
|
|
"amber B".to_string(),
|
|
)
|
|
.await
|
|
.expect("connect account B while A is parked");
|
|
wait_connected(signer.clone()).await;
|
|
let npub_b = SignerTrait::get_public_key(&signer)
|
|
.await
|
|
.expect("identity B")
|
|
.to_bech32()
|
|
.unwrap();
|
|
assert_ne!(npub_a, npub_b, "two accounts, two identities");
|
|
|
|
// B is fully usable: sign through it.
|
|
let unsigned = UnsignedEvent::new(
|
|
identity_b.public_key(),
|
|
Timestamp::now(),
|
|
Kind::TextNote,
|
|
vec![],
|
|
"signed by B".to_string(),
|
|
);
|
|
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
|
|
.await
|
|
.expect("sign through B");
|
|
assert!(signed.verify_signature());
|
|
|
|
// Parking must NOT have revoked A: its row stays live with its client
|
|
// key resolvable — that is what makes it restorable.
|
|
let ref_a =
|
|
keynectr::signer::VaultRef::new(Some(npub_a.clone()), comms_a.public_key().to_hex());
|
|
{
|
|
let g = app.lock().await;
|
|
let row = g
|
|
.vault
|
|
.nip46_connections
|
|
.iter()
|
|
.find(|c| c.profile_npub.as_deref() == Some(npub_a.as_str()))
|
|
.expect("A's connection row survives B's pairing");
|
|
assert!(
|
|
row.revoked_at.is_none(),
|
|
"parked session must not be revoked"
|
|
);
|
|
assert!(
|
|
keynectr::vault::resolve_connection_client_key(&g.vault, None, &ref_a)
|
|
.expect("resolve")
|
|
.is_some(),
|
|
"parked session must keep its client key"
|
|
);
|
|
}
|
|
|
|
// --- 3. Switch back to A: park B, re-dial A — no fresh pairing.
|
|
let dialed = signer
|
|
.switch_to_profile(&npub_a)
|
|
.await
|
|
.expect("switch to A");
|
|
assert!(dialed, "A has a restorable session, switch must re-dial it");
|
|
wait_connected(signer.clone()).await;
|
|
let back = SignerTrait::get_public_key(&signer)
|
|
.await
|
|
.expect("identity after switch");
|
|
assert_eq!(
|
|
back.to_bech32().unwrap(),
|
|
npub_a,
|
|
"switched session must answer as A"
|
|
);
|
|
let unsigned_a = UnsignedEvent::new(
|
|
identity_a.public_key(),
|
|
Timestamp::now(),
|
|
Kind::TextNote,
|
|
vec![],
|
|
"signed by A again".to_string(),
|
|
);
|
|
let signed_a = SignerTrait::sign_event(&signer, unsigned_a.clone())
|
|
.await
|
|
.expect("sign through A after switch");
|
|
assert!(signed_a.verify_signature());
|
|
assert_eq!(signed_a.content, "signed by A again");
|
|
|
|
// Switching to A again is a no-op (already serving A): no second dial.
|
|
assert!(
|
|
!signer
|
|
.switch_to_profile(&npub_a)
|
|
.await
|
|
.expect("noop switch"),
|
|
"switch to the identity already live must not re-dial"
|
|
);
|
|
|
|
// --- 4. A profile with NO signer connection must leave B... (here A)
|
|
// alone: local-key profiles route signing through their own source.
|
|
let local = Keys::generate();
|
|
let npub_local = local.public_key().to_bech32().unwrap();
|
|
{
|
|
let mut g = app.lock().await;
|
|
g.vault.profiles.push(keynectr::vault::StoredProfile {
|
|
label: "local".to_string(),
|
|
public_key: npub_local.clone(),
|
|
secret_key: local
|
|
.secret_key()
|
|
.to_secret_bytes()
|
|
.iter()
|
|
.map(|b| format!("{b:02x}"))
|
|
.collect(),
|
|
created_at: 0,
|
|
picture: None,
|
|
nip05: None,
|
|
signer_mode: keynectr::vault::SignerMode::Embedded,
|
|
});
|
|
g.save_vault().unwrap();
|
|
}
|
|
assert!(
|
|
!signer
|
|
.switch_to_profile(&npub_local)
|
|
.await
|
|
.expect("switch to local"),
|
|
"local-key profile must not touch the live signer session"
|
|
);
|
|
assert!(
|
|
signer.status().await.connected,
|
|
"live A session survives a switch to a local profile"
|
|
);
|
|
let still_a = SignerTrait::get_public_key(&signer).await.expect("still A");
|
|
assert_eq!(still_a.to_bech32().unwrap(), npub_a);
|
|
|
|
// And switching back to B works too — B was parked, never revoked.
|
|
let dialed_b = signer
|
|
.switch_to_profile(&npub_b)
|
|
.await
|
|
.expect("switch back to B");
|
|
assert!(dialed_b, "B was parked, must be restorable");
|
|
wait_connected(signer.clone()).await;
|
|
let b_again = SignerTrait::get_public_key(&signer).await.expect("B again");
|
|
assert_eq!(b_again.to_bech32().unwrap(), npub_b);
|
|
|
|
signer.disconnect().await.ok();
|
|
}
|