feat(signer): accept bunker:// URIs, async signer permissions, NIP-46 e2e test

- SignerManager/SignerModeScreen parse both nostrconnect:// and bunker://
  (Amber presents bunker://; signer pubkey extracted before '@')
- Signer permission surface made async (permissions, can_*, is_connection_valid)
- tests/nip46_e2e.rs: full client handshake against fake Amber over a local
  relay — NIP-44 round-trip, get_public_key identity, signed-event verification,
  vault persistence asserting no secret material for remote profiles
- prettier formatting of touched frontend files
This commit is contained in:
Avi 2026-09-12 02:40:40 -05:00
commit 85756df081
11 changed files with 654 additions and 116 deletions

450
tests/nip46_e2e.rs Normal file
View file

@ -0,0 +1,450 @@
//! 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`.
//!
//! 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.
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;
// ---------------------------------------------------------------------------
// 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) {
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.
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();
}
}
// ---------------------------------------------------------------------------
// The test
// ---------------------------------------------------------------------------
#[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")));
// 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
}