mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Handle remote signer key hints for NIP-46
This commit is contained in:
1 parent
37a7ecfaf9
commit
eb9b97bcae
3 files changed
+236
-35
No files matched your search
@@ -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
@@ -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 || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in new issue
Block a user