From aedde8ffb8ea3841fe040ff6dfa181e0d384b852 Mon Sep 17 00:00:00 2001 From: Avi Date: Sat, 12 Sep 2026 21:38:46 -0500 Subject: [PATCH] fix(pairing): accept non-string NIP-46 params (Amber sends requested_perms as a JSON object) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RawRequest used a strict Vec, so Amber's connect request — whose params[1] is the requested_perms grant object — failed to parse and was silently dropped. The signer saw a live session; we saw nothing. Params now coerce non-string values to their JSON text, with regression tests for both shapes. --- src/signer/nip46_client.rs | 61 +++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs index 5999af5..40cd324 100644 --- a/src/signer/nip46_client.rs +++ b/src/signer/nip46_client.rs @@ -589,9 +589,8 @@ impl Nip46ClientSigner { // Local-only debug capture (never leaves this machine): keep the // full event so a failed handshake can be dissected offline. { - let path = std::path::Path::new( - "/home/avi/Tools/keynctr-debug/pairing-capture.jsonl", - ); + let path = + std::path::Path::new("/home/avi/Tools/keynctr-debug/pairing-capture.jsonl"); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } @@ -1861,10 +1860,29 @@ impl Signer for Nip46ClientSigner { struct RawRequest { id: String, method: String, - #[serde(default)] + #[serde(default, deserialize_with = "lenient_params")] params: Vec, } +/// NIP-46 params are conventionally strings, but real signers embed raw JSON +/// objects (Amber sends its requested_perms grant object as `connect` +/// params[1]). A strict `Vec` would reject the entire request and +/// silently drop the handshake, so non-string params are coerced to their +/// JSON text instead. +fn lenient_params<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let values: Vec = Vec::deserialize(deserializer)?; + Ok(values + .into_iter() + .map(|v| match v { + serde_json::Value::String(s) => s, + other => other.to_string(), + }) + .collect()) +} + fn response_ok(id: &str, result: String) -> String { json!({ "id": id, "result": result, "error": null }).to_string() } @@ -1934,3 +1952,38 @@ fn percent_decode(raw: &str) -> Option { } String::from_utf8(out).ok() } + +#[cfg(test)] +mod raw_request_tests { + use super::RawRequest; + + #[test] + fn parses_amber_style_connect_with_object_params() { + // Real signers (Amber) send requested_perms as a raw JSON object in + // params[1]; a strict Vec used to reject the whole request + // and silently drop the pairing handshake. + let raw = format!( + r#"{{"id":"1","method":"connect","params":["{}",{{"sign_event":[1,7],"nip04_encrypt":true}}]}}"#, + "a".repeat(64) + ); + let req: RawRequest = serde_json::from_str(&raw).expect("must parse"); + assert_eq!(req.method, "connect"); + assert_eq!(req.params.len(), 2); + assert_eq!(req.params[0], "a".repeat(64)); + assert!( + req.params[1].starts_with('{'), + "object coerced to JSON text" + ); + } + + #[test] + fn parses_string_params_unchanged() { + let req: RawRequest = + serde_json::from_str(r#"{"id":"2","method":"get_public_key","params":[]}"#).unwrap(); + assert!(req.params.is_empty()); + let req: RawRequest = + serde_json::from_str(r#"{"id":"3","method":"nip44_decrypt","params":["aa","bb"]}"#) + .unwrap(); + assert_eq!(req.params, vec!["aa".to_string(), "bb".to_string()]); + } +}