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
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
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,
createNip46Cipher,
normalizeNip46CiphertextPayload,
parseNip46ConnectionString,
attemptDecryptNip46HandshakePayload,
};
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
@@ -3556,24 +3558,31 @@ function parseNip46ConnectionString(uri) {
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 params = parsed.searchParams || new URLSearchParams();
const relays = params
.getAll("relay")
.map((relay) => {
if (typeof relay !== "string") {
return "";
}
try {
return decodeURIComponent(relay.trim());
} catch (error) {
return relay.trim();
}
})
.map((relay) => decodeParam(relay))
.filter(Boolean);
const permissionsParam = params.get("perms") || params.get("permissions") || "";
const permissionsParam =
decodeParam(params.get("perms")) || decodeParam(params.get("permissions")) || "";
const metadata = {
name: "",
url: "",
@@ -3581,37 +3590,86 @@ function parseNip46ConnectionString(uri) {
};
for (const key of ["name", "url", "image"]) {
const raw = params.get(key);
if (typeof raw === "string" && raw.trim()) {
try {
metadata[key] = decodeURIComponent(raw.trim());
} catch (error) {
metadata[key] = raw.trim();
}
const decoded = decodeParam(params.get(key));
if (decoded) {
metadata[key] = decoded;
}
}
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 userPubkeyHint = "";
if (scheme === "bunker") {
remotePubkey = parsed.hostname || "";
if (!remotePubkey && parsed.pathname && parsed.pathname !== "/") {
remotePubkey = parsed.pathname.replace(/^\/+/, "");
let bunkerIdentifier = parsed.hostname || "";
if (!bunkerIdentifier && parsed.pathname && parsed.pathname !== "/") {
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") {
clientPubkey = parsed.hostname || "";
if (!clientPubkey && parsed.pathname && parsed.pathname !== "/") {
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 {
scheme,
@@ -3619,9 +3677,10 @@ function parseNip46ConnectionString(uri) {
remotePubkey: normalizeNostrPubkey(remotePubkey),
clientPubkey: normalizeNostrPubkey(clientPubkey),
relays,
secret: typeof secretParam === "string" ? secretParam.trim() : "",
secret: secretParam,
permissions: typeof permissionsParam === "string" ? permissionsParam.trim() : "",
metadata,
userPubkeyHint: normalizeNostrPubkey(userPubkeyHint),
};
}
@@ -4063,6 +4122,39 @@ async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciphertext)
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 {
constructor({
nostrClient,
@@ -5336,6 +5428,7 @@ export class NostrClient {
onAuthUrl,
onStatus,
timeoutMs,
expectedRemotePubkey,
} = {}) {
const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey);
if (!normalizedClientPublicKey) {
@@ -5446,14 +5539,22 @@ export class NostrClient {
return;
}
const remotePubkey = normalizeNostrPubkey(event.pubkey);
if (!remotePubkey) {
return;
const eventRemotePubkey = normalizeNostrPubkey(event.pubkey);
const candidateRemotePubkeys = [];
if (expectedRemotePubkey) {
candidateRemotePubkeys.push(expectedRemotePubkey);
}
if (eventRemotePubkey) {
candidateRemotePubkeys.push(eventRemotePubkey);
}
Promise.resolve()
.then(() =>
decryptNip46PayloadWithKeys(clientPrivateKey, remotePubkey, event.content),
attemptDecryptNip46HandshakePayload({
clientPrivateKey,
candidateRemotePubkeys,
ciphertext: event.content,
}),
)
.then((payloadResult) => {
const plaintext = payloadResult?.plaintext ?? "";
@@ -5504,7 +5605,7 @@ export class NostrClient {
phase: "handshake",
state: "acknowledged",
message: "Remote signer acknowledged the connect request.",
remotePubkey,
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
});
} catch (callbackError) {
devLogger.warn("[nostr] Handshake status callback threw:", callbackError);
@@ -5512,7 +5613,8 @@ export class NostrClient {
}
resolve({
remotePubkey,
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
eventPubkey: eventRemotePubkey || "",
response: parsed,
algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm),
});
@@ -5645,6 +5747,8 @@ export class NostrClient {
state: "connecting",
relays,
metadata,
remotePubkey: parsed.remotePubkey || "",
userPubkey: parsed.userPubkeyHint || "",
message: "Waiting for the signer to acknowledge the connection.",
});
@@ -5658,6 +5762,7 @@ export class NostrClient {
onAuthUrl: (url, context) => handleAuthChallenge(url, context),
onStatus: handleStatus,
timeoutMs: handshakeTimeoutMs,
expectedRemotePubkey: parsed.remotePubkey,
});
} catch (error) {
this.emitRemoteSignerChange({
@@ -5692,6 +5797,7 @@ export class NostrClient {
remotePubkey,
relays,
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",
);
});
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",
);
},
);