Files
bitvid/js/nostr/signerRemoteHandshake.js
T
thePR0M3TH3ANandClaude Opus 4.8 56d04cd61a refactor(signer): extract capabilities + NIP-46 handshake wait (10/10)
File-size decomposition of the auth/key-handling manager (no behavior change).

- signerCapabilities.js: the signer capability/permission helpers
  (resolveSignerCapabilities, hydrateExtensionSignerCapabilities,
  attachNipMethodAliases, resolveActiveSigner, plus the permission-message
  helpers). The three historically-public ones are re-exported from
  SignerManager so js/nostr/client.js's imports are unchanged. Dropped the now
  dead-in-SignerManager permission-message imports (they were already unused).
- signerRemoteHandshake.js: _waitForRemoteSignerHandshake lifted to an
  instance-param function (only this.client / this.pendingHandshakeCancel /
  this._decryptHandshakePayload became ctx.*; verified zero leftover `this`),
  with a thin delegator on the manager.

SignerManager.js: 1516 -> 1145 lines (back under its 1156 limit).

Build + full unit suite green, incl. the NIP-46 handshake tests
(nip46-wait-handshake, nip46-prepare-handshake, nip46-silent-restore,
nostr-login-permissions) that exercise this path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-19 16:52:57 -04:00

209 lines
6.4 KiB
JavaScript

// js/nostr/signerRemoteHandshake.js
//
// NIP-46 remote-signer handshake wait, extracted from managers/SignerManager.js
// to keep that module under the file-size budget. Takes the SignerManager
// instance (`ctx`) for its client/pool and cancel hook, so behavior is identical
// to the former method; SignerManager keeps a thin delegator. No behavior change.
import {
normalizeNip46EncryptionAlgorithm,
resolveNip46Relays,
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
attemptDecryptNip46HandshakePayload,
normalizeNostrPubkey,
} from "./nip46Client.js";
import { devLogger } from "../utils/logger.js";
import { HEX64_REGEX } from "../utils/hex.js";
export async function waitForRemoteSignerHandshake(
ctx,
{
clientPrivateKey,
clientPublicKey,
relays,
secret,
onAuthUrl,
onStatus,
timeoutMs,
} = {},
) {
const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey);
if (!normalizedClientPublicKey) {
throw new Error(
"A client public key is required to await the remote signer handshake.",
);
}
if (
!clientPrivateKey ||
typeof clientPrivateKey !== "string" ||
!HEX64_REGEX.test(clientPrivateKey)
) {
throw new Error(
"A client private key is required to await the remote signer handshake.",
);
}
const resolvedRelays = resolveNip46Relays(relays, ctx.client?.relays);
if (!resolvedRelays.length) {
throw new Error("No relays available to complete the remote signer handshake.");
}
const pool = await ctx.client.ensurePool();
const filters = [
{ kinds: [NIP46_RPC_KIND], "#p": [normalizedClientPublicKey] },
];
const waitTimeout =
Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: NIP46_HANDSHAKE_TIMEOUT_MS;
const coerceStructuredString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
for (const entry of value) {
const candidate = coerceStructuredString(entry);
if (candidate) return candidate;
}
return "";
}
if (value && typeof value === "object") {
for (const key of ["secret", "message", "status", "result", "url"]) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
const candidate = coerceStructuredString(value[key]);
if (candidate) return candidate;
}
}
}
return "";
};
return new Promise((resolve, reject) => {
let settled = false;
let subscription;
let timeoutId;
const cleanup = () => {
if (settled) return;
settled = true;
ctx.pendingHandshakeCancel = null;
try {
subscription?.unsub?.();
} catch (error) {
// ignore subscription cleanup failures
}
if (timeoutId) clearTimeout(timeoutId);
};
// Allow the UI (modal close / disconnect) to abort the wait.
ctx.pendingHandshakeCancel = () => {
cleanup();
const error = new Error("Remote signer connection cancelled.");
error.code = "login-cancelled";
reject(error);
};
timeoutId = setTimeout(() => {
cleanup();
const error = new Error(
"Timed out waiting for the remote signer to acknowledge the connection.",
);
error.code = "nip46-handshake-timeout";
reject(error);
}, waitTimeout);
try {
subscription = pool.sub(resolvedRelays, filters);
} catch (error) {
cleanup();
reject(error);
return;
}
subscription.on("event", (event) => {
if (settled || !event || event.kind !== NIP46_RPC_KIND) {
return;
}
const eventRemotePubkey = normalizeNostrPubkey(event.pubkey);
const candidateRemotePubkeys = eventRemotePubkey ? [eventRemotePubkey] : [];
const decryptHandshake =
ctx._decryptHandshakePayload || attemptDecryptNip46HandshakePayload;
Promise.resolve()
.then(() =>
decryptHandshake({
clientPrivateKey,
candidateRemotePubkeys,
ciphertext: event.content,
}),
)
.then((payloadResult) => {
let parsed;
try {
parsed = JSON.parse(payloadResult?.plaintext ?? "");
} catch (error) {
return;
}
const resultValue = coerceStructuredString(parsed?.result);
const errorValue = coerceStructuredString(parsed?.error);
// auth_url challenge: surface to the UI and keep waiting.
if (resultValue === "auth_url" && errorValue) {
if (typeof onAuthUrl === "function") {
try {
onAuthUrl(errorValue, {
phase: "handshake",
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
requestId: typeof parsed?.id === "string" ? parsed.id : "",
});
} catch (callbackError) {
devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError);
}
}
return;
}
// Match the handshake secret (or accept a generic ack).
if (secret) {
const normalizedResult = resultValue ? resultValue.toLowerCase() : "";
if (resultValue !== secret && normalizedResult !== "ack") return;
} else if (resultValue) {
if (!["ack", "ok", "success"].includes(resultValue.toLowerCase())) return;
}
cleanup();
if (typeof onStatus === "function") {
try {
onStatus({
phase: "handshake",
state: "acknowledged",
message: "Remote signer acknowledged the connect request.",
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
});
} catch (callbackError) {
devLogger.warn("[nostr] Handshake status callback threw:", callbackError);
}
}
resolve({
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
eventPubkey: eventRemotePubkey || "",
response: parsed,
algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm),
});
})
.catch((error) => {
devLogger.warn(
"[nostr] Failed to decrypt remote signer handshake payload:",
error,
);
});
});
subscription.on("eose", () => {
// no-op: handshake responses are push-based
});
});
}