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:
parent
84f11e615d
commit
85756df081
11 changed files with 654 additions and 116 deletions
450
tests/nip46_e2e.rs
Normal file
450
tests/nip46_e2e.rs
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue