mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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:
1 parent
370cc67cc5
commit
56d04cd61a
3 files changed
+432
-387
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user