fix(pairing): accept non-string NIP-46 params (Amber sends requested_perms as a JSON object)

RawRequest used a strict Vec<String>, 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.
This commit is contained in:
Avi 2026-09-12 21:38:46 -05:00
commit aedde8ffb8

View file

@ -589,9 +589,8 @@ impl Nip46ClientSigner {
// Local-only debug capture (never leaves this machine): keep the // Local-only debug capture (never leaves this machine): keep the
// full event so a failed handshake can be dissected offline. // full event so a failed handshake can be dissected offline.
{ {
let path = std::path::Path::new( let path =
"/home/avi/Tools/keynctr-debug/pairing-capture.jsonl", std::path::Path::new("/home/avi/Tools/keynctr-debug/pairing-capture.jsonl");
);
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir); let _ = std::fs::create_dir_all(dir);
} }
@ -1861,10 +1860,29 @@ impl Signer for Nip46ClientSigner {
struct RawRequest { struct RawRequest {
id: String, id: String,
method: String, method: String,
#[serde(default)] #[serde(default, deserialize_with = "lenient_params")]
params: Vec<String>, params: Vec<String>,
} }
/// 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<String>` 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<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let values: Vec<serde_json::Value> = 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 { fn response_ok(id: &str, result: String) -> String {
json!({ "id": id, "result": result, "error": null }).to_string() json!({ "id": id, "result": result, "error": null }).to_string()
} }
@ -1934,3 +1952,38 @@ fn percent_decode(raw: &str) -> Option<String> {
} }
String::from_utf8(out).ok() 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<String> 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()]);
}
}