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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-19 16:52:57 -04:00
1 parent 370cc67cc5
commit 56d04cd61a
3 files changed
+432 -387

No files matched your search

+17 -387
View File
@@ -24,8 +24,21 @@ import {
getActiveSigner as getActiveSignerFromRegistry,
clearActiveSigner as clearActiveSignerInRegistry,
logoutSigner as logoutSignerFromRegistry,
resolveActiveSigner as resolveActiveSignerFromRegistry,
} from "../../nostrClientRegistry.js";
import {
resolveSignerCapabilities,
hydrateExtensionSignerCapabilities,
attachNipMethodAliases,
resolveActiveSigner,
} from "../signerCapabilities.js";
// Re-exported for existing importers (e.g. js/nostr/client.js) that historically
// imported these from SignerManager.
export {
resolveSignerCapabilities,
hydrateExtensionSignerCapabilities,
attachNipMethodAliases,
};
import {
readStoredNip46Session,
@@ -59,206 +72,7 @@ import { queueSignEvent } from "../signRequestQueue.js";
import { signEventWithPrivateKey } from "../publishHelpers.js";
import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js";
import { accessControl } from "../../accessControl.js";
export function resolveSignerCapabilities(signer) {
const fallback = {
sign: false,
nip44: false,
nip04: false,
};
if (!signer || typeof signer !== "object") {
return fallback;
}
const capabilities =
signer.capabilities && typeof signer.capabilities === "object"
? signer.capabilities
: {};
return {
sign:
(typeof capabilities.sign === "boolean" && capabilities.sign) ||
typeof signer.signEvent === "function",
nip44:
(typeof capabilities.nip44 === "boolean" && capabilities.nip44) ||
typeof signer.nip44Encrypt === "function" ||
typeof signer.nip44Decrypt === "function",
nip04:
(typeof capabilities.nip04 === "boolean" && capabilities.nip04) ||
typeof signer.nip04Encrypt === "function" ||
typeof signer.nip04Decrypt === "function",
};
}
export function hydrateExtensionSignerCapabilities(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const signerType = typeof signer.type === "string" ? signer.type : "";
if (signerType !== "extension" && signerType !== "nip07") {
return;
}
const extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension) {
return;
}
if (typeof signer.signEvent !== "function" && extension.signEvent) {
if (typeof extension.signEvent === "function") {
signer.signEvent = extension.signEvent.bind(extension);
}
}
if (!signer.nip04 && extension.nip04) {
signer.nip04 = extension.nip04;
}
if (!signer.nip44 && extension.nip44) {
signer.nip44 = extension.nip44;
}
}
export function attachNipMethodAliases(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const nip04 =
signer && typeof signer.nip04 === "object" && signer.nip04 !== null
? signer.nip04
: null;
if (nip04) {
const encrypt =
typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
const decrypt =
typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null;
if (encrypt && typeof signer.nip04Encrypt !== "function") {
signer.nip04Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt && typeof signer.nip04Decrypt !== "function") {
signer.nip04Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
const nip44 =
signer && typeof signer.nip44 === "object" && signer.nip44 !== null
? signer.nip44
: null;
if (nip44) {
const v2 =
typeof nip44.v2 === "object" && nip44.v2 !== null ? nip44.v2 : null;
const encrypt = (() => {
if (typeof signer.nip44Encrypt === "function") {
return null;
}
if (typeof v2?.encrypt === "function") {
return v2.encrypt.bind(v2);
}
if (typeof nip44.encrypt === "function") {
return nip44.encrypt.bind(nip44);
}
return null;
})();
const decrypt = (() => {
if (typeof signer.nip44Decrypt === "function") {
return null;
}
if (typeof v2?.decrypt === "function") {
return v2.decrypt.bind(v2);
}
if (typeof nip44.decrypt === "function") {
return nip44.decrypt.bind(nip44);
}
return null;
})();
if (encrypt) {
signer.nip44Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt) {
signer.nip44Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
}
function resolveActiveSigner(pubkey) {
const signer = resolveActiveSignerFromRegistry(pubkey);
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
const PERMISSION_STATUS_AUTO_HIDE_MS = 12_000;
const ENCRYPTION_METHOD_PREFIXES = ["nip04.", "nip44."];
function hasEncryptionPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some((method) =>
ENCRYPTION_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)),
);
}
function hasSigningPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some(
(method) => method === "sign_event" || method === "get_public_key",
);
}
function resolvePermissionStatusMessage(methods, context) {
const normalizedContext =
typeof context === "string" ? context.trim().toLowerCase() : "";
if (normalizedContext === "dm") {
return "Approve the extension prompt to enable encrypted direct messages.";
}
if (normalizedContext === "lists") {
return "Approve the extension prompt to access your encrypted lists.";
}
const includesEncryption = hasEncryptionPermissionMethods(methods);
const includesSigning = hasSigningPermissionMethods(methods);
if (includesEncryption && includesSigning) {
return "Approve the extension prompt to enable signing and encrypted features (DMs, subscriptions, block lists).";
}
if (includesEncryption) {
return "Approve the extension prompt to enable encrypted features like DMs and private lists.";
}
if (includesSigning) {
return "Approve the extension prompt to enable signing.";
}
return "Approve the extension prompt to continue.";
}
import { waitForRemoteSignerHandshake } from "../signerRemoteHandshake.js";
export class SignerManager {
constructor(client) {
@@ -754,192 +568,8 @@ export class SignerManager {
*
* @returns {Promise<{remotePubkey:string, eventPubkey:string, response:object, algorithm:string}>}
*/
async _waitForRemoteSignerHandshake({
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, this.client?.relays);
if (!resolvedRelays.length) {
throw new Error("No relays available to complete the remote signer handshake.");
}
const pool = await this.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;
this.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.
this.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 =
this._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
});
});
async _waitForRemoteSignerHandshake(options = {}) {
return waitForRemoteSignerHandshake(this, options);
}
async connectRemoteSigner({
+207
View File
@@ -0,0 +1,207 @@
// js/nostr/signerCapabilities.js
//
// Signer capability/permission helpers extracted from managers/SignerManager.js
// to keep that module under the file-size budget. These operate on a signer
// object (or plain method lists) passed in — no SignerManager instance state —
// so they move cleanly. No behavior change.
import { resolveActiveSigner as resolveActiveSignerFromRegistry } from "../nostrClientRegistry.js";
export function resolveSignerCapabilities(signer) {
const fallback = {
sign: false,
nip44: false,
nip04: false,
};
if (!signer || typeof signer !== "object") {
return fallback;
}
const capabilities =
signer.capabilities && typeof signer.capabilities === "object"
? signer.capabilities
: {};
return {
sign:
(typeof capabilities.sign === "boolean" && capabilities.sign) ||
typeof signer.signEvent === "function",
nip44:
(typeof capabilities.nip44 === "boolean" && capabilities.nip44) ||
typeof signer.nip44Encrypt === "function" ||
typeof signer.nip44Decrypt === "function",
nip04:
(typeof capabilities.nip04 === "boolean" && capabilities.nip04) ||
typeof signer.nip04Encrypt === "function" ||
typeof signer.nip04Decrypt === "function",
};
}
export function hydrateExtensionSignerCapabilities(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const signerType = typeof signer.type === "string" ? signer.type : "";
if (signerType !== "extension" && signerType !== "nip07") {
return;
}
const extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension) {
return;
}
if (typeof signer.signEvent !== "function" && extension.signEvent) {
if (typeof extension.signEvent === "function") {
signer.signEvent = extension.signEvent.bind(extension);
}
}
if (!signer.nip04 && extension.nip04) {
signer.nip04 = extension.nip04;
}
if (!signer.nip44 && extension.nip44) {
signer.nip44 = extension.nip44;
}
}
export function attachNipMethodAliases(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const nip04 =
signer && typeof signer.nip04 === "object" && signer.nip04 !== null
? signer.nip04
: null;
if (nip04) {
const encrypt =
typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
const decrypt =
typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null;
if (encrypt && typeof signer.nip04Encrypt !== "function") {
signer.nip04Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt && typeof signer.nip04Decrypt !== "function") {
signer.nip04Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
const nip44 =
signer && typeof signer.nip44 === "object" && signer.nip44 !== null
? signer.nip44
: null;
if (nip44) {
const v2 =
typeof nip44.v2 === "object" && nip44.v2 !== null ? nip44.v2 : null;
const encrypt = (() => {
if (typeof signer.nip44Encrypt === "function") {
return null;
}
if (typeof v2?.encrypt === "function") {
return v2.encrypt.bind(v2);
}
if (typeof nip44.encrypt === "function") {
return nip44.encrypt.bind(nip44);
}
return null;
})();
const decrypt = (() => {
if (typeof signer.nip44Decrypt === "function") {
return null;
}
if (typeof v2?.decrypt === "function") {
return v2.decrypt.bind(v2);
}
if (typeof nip44.decrypt === "function") {
return nip44.decrypt.bind(nip44);
}
return null;
})();
if (encrypt) {
signer.nip44Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt) {
signer.nip44Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
}
export function resolveActiveSigner(pubkey) {
const signer = resolveActiveSignerFromRegistry(pubkey);
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
const ENCRYPTION_METHOD_PREFIXES = ["nip04.", "nip44."];
export function hasEncryptionPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some((method) =>
ENCRYPTION_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)),
);
}
export function hasSigningPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some(
(method) => method === "sign_event" || method === "get_public_key",
);
}
export function resolvePermissionStatusMessage(methods, context) {
const normalizedContext =
typeof context === "string" ? context.trim().toLowerCase() : "";
if (normalizedContext === "dm") {
return "Approve the extension prompt to enable encrypted direct messages.";
}
if (normalizedContext === "lists") {
return "Approve the extension prompt to access your encrypted lists.";
}
const includesEncryption = hasEncryptionPermissionMethods(methods);
const includesSigning = hasSigningPermissionMethods(methods);
if (includesEncryption && includesSigning) {
return "Approve the extension prompt to enable signing and encrypted features (DMs, subscriptions, block lists).";
}
if (includesEncryption) {
return "Approve the extension prompt to enable encrypted features like DMs and private lists.";
}
if (includesSigning) {
return "Approve the extension prompt to enable signing.";
}
return "Approve the extension prompt to continue.";
}
+208
View File
@@ -0,0 +1,208 @@
// 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
});
});
}