Handle remote signer key hints for NIP-46

This commit is contained in:
thePR0M3TH3AN committed 2025-10-22 08:58:31 -04:00
1 parent 37a7ecfaf9
commit eb9b97bcae
3 files changed
+236 -35

No files matched your search

+12
View File
@@ -36,6 +36,18 @@ URI manually when QR pairing isn’t available. These URIs can advertise the sam
`relay=`, `secret=`, `perms=`, `name`, `url`, and `image` parameters described `relay=`, `secret=`, `perms=`, `name`, `url`, and `image` parameters described
above. If no relays are specified the default bundle listed earlier is used. above. If no relays are specified the default bundle listed earlier is used.
Recent remote signers also append a `remote-signer-key=` parameter that
identifies the key the bunker uses for encrypted RPC traffic. The hostname/path
may now refer to the *user* pubkey instead of the signer key, so clients must:
1. Decrypt handshake acknowledgements with the `remote-signer-key` when
provided, falling back to the event author as a legacy escape hatch.
2. Continue calling `get_public_key` after the `connect` RPC to learn the user
pubkey rather than trusting the bunker URI host.
bitvid automatically persists both the signer key and the user pubkey returned
by `get_public_key` so reconnect flows display accurate metadata.
## Relay requirements ## Relay requirements
The remote signer must subscribe to `kind 24133` events targeted at the client The remote signer must subscribe to `kind 24133` events targeted at the client
+141 -35
View File
@@ -762,6 +762,8 @@ export const __testExports = {
decryptNip46PayloadWithKeys, decryptNip46PayloadWithKeys,
createNip46Cipher, createNip46Cipher,
normalizeNip46CiphertextPayload, normalizeNip46CiphertextPayload,
parseNip46ConnectionString,
attemptDecryptNip46HandshakePayload,
}; };
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") { function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
@@ -3556,24 +3558,31 @@ function parseNip46ConnectionString(uri) {
return null; return null;
} }
const decodeParam = (raw) => {
if (typeof raw !== "string") {
return "";
}
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
try {
return decodeURIComponent(trimmed);
} catch (error) {
return trimmed;
}
};
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
const params = parsed.searchParams || new URLSearchParams(); const params = parsed.searchParams || new URLSearchParams();
const relays = params const relays = params
.getAll("relay") .getAll("relay")
.map((relay) => { .map((relay) => decodeParam(relay))
if (typeof relay !== "string") {
return "";
}
try {
return decodeURIComponent(relay.trim());
} catch (error) {
return relay.trim();
}
})
.filter(Boolean); .filter(Boolean);
const permissionsParam = params.get("perms") || params.get("permissions") || ""; const permissionsParam =
decodeParam(params.get("perms")) || decodeParam(params.get("permissions")) || "";
const metadata = { const metadata = {
name: "", name: "",
url: "", url: "",
@@ -3581,37 +3590,86 @@ function parseNip46ConnectionString(uri) {
}; };
for (const key of ["name", "url", "image"]) { for (const key of ["name", "url", "image"]) {
const raw = params.get(key); const decoded = decodeParam(params.get(key));
if (typeof raw === "string" && raw.trim()) { if (decoded) {
try { metadata[key] = decoded;
metadata[key] = decodeURIComponent(raw.trim());
} catch (error) {
metadata[key] = raw.trim();
}
} }
} }
let remotePubkey = ""; const remoteSignerParamKeys = [
"remote-signer-key",
"remote_signer_key",
"remotesignerkey",
"remoteSignerKey",
"remote-signer-pubkey",
"remote_signer_pubkey",
"remoteSignerPubkey",
];
let remoteSignerParam = "";
for (const key of remoteSignerParamKeys) {
const candidate = decodeParam(params.get(key));
if (candidate) {
remoteSignerParam = candidate;
break;
}
}
const remoteFallbackParam =
decodeParam(params.get("remote")) ||
decodeParam(params.get("remotePubkey")) ||
decodeParam(params.get("signer")) ||
"";
let remotePubkey = remoteSignerParam || remoteFallbackParam || "";
let clientPubkey = ""; let clientPubkey = "";
let userPubkeyHint = "";
if (scheme === "bunker") { if (scheme === "bunker") {
remotePubkey = parsed.hostname || ""; let bunkerIdentifier = parsed.hostname || "";
if (!remotePubkey && parsed.pathname && parsed.pathname !== "/") { if (!bunkerIdentifier && parsed.pathname && parsed.pathname !== "/") {
remotePubkey = parsed.pathname.replace(/^\/+/, ""); bunkerIdentifier = parsed.pathname.replace(/^\/+/, "");
}
const normalizedIdentifier = normalizeNostrPubkey(bunkerIdentifier);
if (remoteSignerParam) {
userPubkeyHint = normalizedIdentifier;
} else if (!remotePubkey) {
remotePubkey = normalizedIdentifier;
}
if (!userPubkeyHint) {
const userParamCandidates = [
"user",
"user_pubkey",
"user-pubkey",
"pubkey",
"npub",
"profile",
];
for (const key of userParamCandidates) {
const candidate = decodeParam(params.get(key));
if (candidate) {
userPubkeyHint = normalizeNostrPubkey(candidate);
if (userPubkeyHint) {
break;
}
}
}
if (!userPubkeyHint && normalizedIdentifier && normalizedIdentifier !== remotePubkey) {
userPubkeyHint = normalizedIdentifier;
}
} }
} else if (scheme === "nostrconnect" || scheme === "web+nostrconnect") { } else if (scheme === "nostrconnect" || scheme === "web+nostrconnect") {
clientPubkey = parsed.hostname || ""; clientPubkey = parsed.hostname || "";
if (!clientPubkey && parsed.pathname && parsed.pathname !== "/") { if (!clientPubkey && parsed.pathname && parsed.pathname !== "/") {
clientPubkey = parsed.pathname.replace(/^\/+/, ""); clientPubkey = parsed.pathname.replace(/^\/+/, "");
} }
remotePubkey =
params.get("remote") ||
params.get("remotePubkey") ||
params.get("signer") ||
"";
} }
const secretParam = params.get("secret") || ""; const secretParam = decodeParam(params.get("secret"));
return { return {
scheme, scheme,
@@ -3619,9 +3677,10 @@ function parseNip46ConnectionString(uri) {
remotePubkey: normalizeNostrPubkey(remotePubkey), remotePubkey: normalizeNostrPubkey(remotePubkey),
clientPubkey: normalizeNostrPubkey(clientPubkey), clientPubkey: normalizeNostrPubkey(clientPubkey),
relays, relays,
secret: typeof secretParam === "string" ? secretParam.trim() : "", secret: secretParam,
permissions: typeof permissionsParam === "string" ? permissionsParam.trim() : "", permissions: typeof permissionsParam === "string" ? permissionsParam.trim() : "",
metadata, metadata,
userPubkeyHint: normalizeNostrPubkey(userPubkeyHint),
}; };
} }
@@ -4063,6 +4122,39 @@ async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext)
throw failure; throw failure;
} }
async function attemptDecryptNip46HandshakePayload({
clientPrivateKey,
candidateRemotePubkeys = [],
ciphertext,
}) {
const tried = [];
const seen = new Set();
for (const candidate of candidateRemotePubkeys) {
const normalized = normalizeNostrPubkey(candidate);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
try {
const result = await decryptNip46PayloadWithKeys(
clientPrivateKey,
normalized,
ciphertext,
);
return { ...result, remotePubkey: normalized };
} catch (error) {
tried.push({ remotePubkey: normalized, error });
}
}
const failure = new Error(
"Failed to decrypt remote signer handshake payload with provided keys.",
);
failure.attempts = tried;
throw failure;
}
class Nip46RpcClient { class Nip46RpcClient {
constructor({ constructor({
nostrClient, nostrClient,
@@ -5336,6 +5428,7 @@ export class NostrClient {
onAuthUrl, onAuthUrl,
onStatus, onStatus,
timeoutMs, timeoutMs,
expectedRemotePubkey,
} = {}) { } = {}) {
const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey); const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey);
if (!normalizedClientPublicKey) { if (!normalizedClientPublicKey) {
@@ -5446,14 +5539,22 @@ export class NostrClient {
return; return;
} }
const remotePubkey = normalizeNostrPubkey(event.pubkey); const eventRemotePubkey = normalizeNostrPubkey(event.pubkey);
if (!remotePubkey) { const candidateRemotePubkeys = [];
return; if (expectedRemotePubkey) {
candidateRemotePubkeys.push(expectedRemotePubkey);
}
if (eventRemotePubkey) {
candidateRemotePubkeys.push(eventRemotePubkey);
} }
Promise.resolve() Promise.resolve()
.then(() => .then(() =>
decryptNip46PayloadWithKeys(clientPrivateKey, remotePubkey, event.content), attemptDecryptNip46HandshakePayload({
clientPrivateKey,
candidateRemotePubkeys,
ciphertext: event.content,
}),
) )
.then((payloadResult) => { .then((payloadResult) => {
const plaintext = payloadResult?.plaintext ?? ""; const plaintext = payloadResult?.plaintext ?? "";
@@ -5504,7 +5605,7 @@ export class NostrClient {
phase: "handshake", phase: "handshake",
state: "acknowledged", state: "acknowledged",
message: "Remote signer acknowledged the connect request.", message: "Remote signer acknowledged the connect request.",
remotePubkey, remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
}); });
} catch (callbackError) { } catch (callbackError) {
devLogger.warn("[nostr] Handshake status callback threw:", callbackError); devLogger.warn("[nostr] Handshake status callback threw:", callbackError);
@@ -5512,7 +5613,8 @@ export class NostrClient {
} }
resolve({ resolve({
remotePubkey, remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
eventPubkey: eventRemotePubkey || "",
response: parsed, response: parsed,
algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm), algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm),
}); });
@@ -5645,6 +5747,8 @@ export class NostrClient {
state: "connecting", state: "connecting",
relays, relays,
metadata, metadata,
remotePubkey: parsed.remotePubkey || "",
userPubkey: parsed.userPubkeyHint || "",
message: "Waiting for the signer to acknowledge the connection.", message: "Waiting for the signer to acknowledge the connection.",
}); });
@@ -5658,6 +5762,7 @@ export class NostrClient {
onAuthUrl: (url, context) => handleAuthChallenge(url, context), onAuthUrl: (url, context) => handleAuthChallenge(url, context),
onStatus: handleStatus, onStatus: handleStatus,
timeoutMs: handshakeTimeoutMs, timeoutMs: handshakeTimeoutMs,
expectedRemotePubkey: parsed.remotePubkey,
}); });
} catch (error) { } catch (error) {
this.emitRemoteSignerChange({ this.emitRemoteSignerChange({
@@ -5692,6 +5797,7 @@ export class NostrClient {
remotePubkey, remotePubkey,
relays, relays,
metadata, metadata,
userPubkey: parsed.userPubkeyHint || "",
}); });
} }
+83
View File
@@ -288,3 +288,86 @@ test("decryptNip46PayloadWithKeys supports nip04-style ciphertext wrappers", asy
"nip04 decrypt should succeed for structured payload candidates", "nip04 decrypt should succeed for structured payload candidates",
); );
}); });
test("parseNip46ConnectionString handles remote signer key hints", async () => {
const nostrTools = await loadNostrTools();
const { generateSecretKey, getPublicKey, nip19, utils } = nostrTools;
const signerSecret = utils.bytesToHex(generateSecretKey());
const userSecret = utils.bytesToHex(generateSecretKey());
const signerPubkey = getPublicKey(signerSecret).toLowerCase();
const userPubkey = getPublicKey(userSecret).toLowerCase();
const signerNpub = nip19.npubEncode(signerPubkey);
const userNpub = nip19.npubEncode(userPubkey);
const uri =
`bunker://${userNpub}?remote-signer-key=${encodeURIComponent(signerNpub)}` +
`&relay=${encodeURIComponent("wss://relay.example.com")}`;
const { __testExports } = await import("../js/nostr.js");
const { parseNip46ConnectionString } = __testExports;
const parsed = parseNip46ConnectionString(uri);
assert.ok(parsed, "parser should return a payload");
assert.equal(
parsed.remotePubkey,
signerPubkey,
"remote signer pubkey should come from the remote-signer-key parameter",
);
assert.equal(
parsed.userPubkeyHint,
userPubkey,
"user pubkey hint should reflect the bunker URI hostname",
);
assert.deepEqual(
parsed.relays,
["wss://relay.example.com"],
"relay parameters should be decoded",
);
});
test(
"attemptDecryptNip46HandshakePayload falls back to expected remote signer key",
async () => {
const nostrTools = await loadNostrTools();
const { generateSecretKey, getPublicKey, nip44, utils } = nostrTools;
const clientSecret = utils.bytesToHex(generateSecretKey());
const remoteSignerSecret = utils.bytesToHex(generateSecretKey());
const userSecret = utils.bytesToHex(generateSecretKey());
const remoteSignerPubkey = getPublicKey(remoteSignerSecret).toLowerCase();
const userPubkey = getPublicKey(userSecret).toLowerCase();
const conversationKey =
typeof nip44.v2.getConversationKey === "function"
? nip44.v2.getConversationKey(clientSecret, remoteSignerPubkey)
: nip44.v2.utils.getConversationKey(clientSecret, remoteSignerPubkey);
const payload = JSON.stringify({ id: "ack", result: "ok" });
const ciphertext = nip44.v2.encrypt(payload, conversationKey);
const { __testExports } = await import("../js/nostr.js");
const { attemptDecryptNip46HandshakePayload } = __testExports;
const result = await attemptDecryptNip46HandshakePayload({
clientPrivateKey: clientSecret,
candidateRemotePubkeys: [userPubkey, remoteSignerPubkey],
ciphertext,
});
assert.equal(
result.remotePubkey,
remoteSignerPubkey,
"helper should return the remote signer key that successfully decrypted the payload",
);
assert.equal(
result.plaintext,
payload,
"helper should yield the handshake plaintext",
);
},
);