diff --git a/js/nostr/client.js b/js/nostr/client.js index bdddc40c..859ebd08 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -215,7 +215,12 @@ import { queueSignEvent } from "./signRequestQueue.js"; import { EventsMap } from "./eventsMap.js"; import { PersistenceManager } from "./managers/PersistenceManager.js"; import { ConnectionManager } from "./managers/ConnectionManager.js"; -import { SignerManager } from "./managers/SignerManager.js"; +import { + SignerManager, + resolveSignerCapabilities, + hydrateExtensionSignerCapabilities, + attachNipMethodAliases, +} from "./managers/SignerManager.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -235,6 +240,15 @@ function resolveActiveSigner(pubkey) { } function setActiveSigner(signer) { + if (signer && typeof signer === "object") { + hydrateExtensionSignerCapabilities(signer); + attachNipMethodAliases(signer); + const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities"); + const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; + if (!isGetter) { + signer.capabilities = resolveSignerCapabilities(signer); + } + } setActiveSignerInRegistry(signer); } @@ -754,6 +768,8 @@ export class NostrClient { get sessionActorCipherClosuresPrivateKey() { return this.signerManager.sessionActorCipherClosuresPrivateKey; } set sessionActorCipherClosuresPrivateKey(val) { this.signerManager.sessionActorCipherClosuresPrivateKey = val; } + get extensionPermissionCache() { return this.signerManager.extensionPermissionCache; } + get pool() { return this.connectionManager.pool; } set pool(val) { this.connectionManager.pool = val; } @@ -2914,9 +2930,39 @@ export class NostrClient { } async loginWithExtension(options) { + // Check if ensureExtensionPermissions was overridden on this instance (e.g. by tests). + // Only proxy when the own property differs from the prototype method to avoid recursion. + const ownDesc = Object.getOwnPropertyDescriptor(this, "ensureExtensionPermissions"); + const isOverridden = + ownDesc && + typeof ownDesc.value === "function" && + ownDesc.value !== NostrClient.prototype.ensureExtensionPermissions; + + if (isOverridden) { + const sm = this.signerManager; + const smOriginal = sm.ensureExtensionPermissions.bind(sm); + sm.ensureExtensionPermissions = (...args) => ownDesc.value.call(this, ...args); + try { + return await sm.loginWithExtension(options); + } finally { + sm.ensureExtensionPermissions = smOriginal; + } + } return this.signerManager.loginWithExtension(options); } + async derivePrivateKeyFromSecret(secret) { + return this.signerManager.derivePrivateKeyFromSecret(secret); + } + + async registerPrivateKeySigner(params) { + return this.signerManager.registerPrivateKeySigner(params); + } + + installNip46Client(rpcClient, options) { + return this.signerManager.installNip46Client(rpcClient, options); + } + async connectRemoteSigner(params) { return this.signerManager.connectRemoteSigner(params); } diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index e794c862..2f1cd79e 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -48,12 +48,13 @@ import { } from "../nip46LoggingUtils.js"; import { devLogger, userLogger } from "../../utils/logger.js"; +import { HEX64_REGEX } from "../../utils/hex.js"; import { createPrivateKeyCipherClosures } from "../signerHelpers.js"; import { queueSignEvent } from "../signRequestQueue.js"; import { signEventWithPrivateKey } from "../publishHelpers.js"; import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js"; -function resolveSignerCapabilities(signer) { +export function resolveSignerCapabilities(signer) { const fallback = { sign: false, nip44: false, @@ -84,7 +85,7 @@ function resolveSignerCapabilities(signer) { }; } -function hydrateExtensionSignerCapabilities(signer) { +export function hydrateExtensionSignerCapabilities(signer) { if (!signer || typeof signer !== "object") { return; } @@ -115,7 +116,7 @@ function hydrateExtensionSignerCapabilities(signer) { } } -function attachNipMethodAliases(signer) { +export function attachNipMethodAliases(signer) { if (!signer || typeof signer !== "object") { return; } @@ -263,6 +264,21 @@ export class SignerManager { this.extensionReady = false; this.extensionPermissionsGranted = false; this.extensionPermissionCache = new Map(); + + // Hydrate permission cache from localStorage so fresh instances + // recognise previously granted permissions without re-prompting. + try { + const storedPermissions = readStoredNip07Permissions(); + if (storedPermissions.size > 0) { + for (const method of storedPermissions) { + this.extensionPermissionCache.set(method, true); + } + this.extensionPermissionsGranted = true; + } + } catch (_) { + // Storage unavailable or corrupt — start with empty cache + } + this.sessionActorCipherClosures = null; this.sessionActorCipherClosuresPrivateKey = null; this.remoteSignerListeners = new Set(); @@ -423,11 +439,15 @@ export class SignerManager { if (missing.length === 0) { this.extensionPermissionsGranted = true; this.extensionPermissionCache.set(cacheKey, true); + for (const method of methods) { + this.extensionPermissionCache.set(method, true); + } return { ok: true }; } + let extension; try { - await waitForNip07Extension(); + extension = await waitForNip07Extension(); } catch (error) { return { ok: false, error: "extension-missing" }; } @@ -435,11 +455,14 @@ export class SignerManager { const message = resolvePermissionStatusMessage(missing, context); try { - const response = await requestEnablePermissions(missing); - if (response?.enabled) { + const response = await requestEnablePermissions(extension, missing); + if (response?.ok) { writeStoredNip07Permissions(missing); this.extensionPermissionsGranted = true; this.extensionPermissionCache.set(cacheKey, true); + for (const method of methods) { + this.extensionPermissionCache.set(method, true); + } return { ok: true }; } return { ok: false, error: "permission-denied" }; @@ -538,7 +561,9 @@ export class SignerManager { const adapter = { type: "extension", pubkey: normalized, - signEvent: extension.signEvent.bind(extension), + signEvent: typeof extension.signEvent === "function" + ? extension.signEvent.bind(extension) + : undefined, nip04: extension.nip04, nip44: extension.nip44, }; @@ -927,4 +952,112 @@ export class SignerManager { this.extensionPermissionsGranted = false; this.emitRemoteSignerChange(); } + + installNip46Client(rpcClient, { userPubkey } = {}) { + this.nip46Client = rpcClient; + if (userPubkey) { + this.pubkey = userPubkey; + } + if (rpcClient && typeof rpcClient.getActiveSigner === "function") { + const signer = rpcClient.getActiveSigner(); + if (signer) { + this.setActiveSigner(signer); + } + } + this.emitRemoteSignerChange(); + } + + async derivePrivateKeyFromSecret(secret) { + if (!secret || typeof secret !== "string") { + throw new Error("A private key or nsec string is required."); + } + + const trimmed = secret.trim(); + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + + // Handle hex private key + if (HEX64_REGEX.test(trimmed)) { + const privateKeyHex = trimmed.toLowerCase(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Nostr tools unavailable for key derivation."); + } + const pubkey = tools.getPublicKey(privateKeyHex); + return { privateKey: privateKeyHex, pubkey }; + } + + // Handle nsec bech32 encoding + if (trimmed.startsWith("nsec1")) { + const nip19 = tools?.nip19; + if (!nip19 || typeof nip19.decode !== "function") { + throw new Error("Nostr tools unavailable for nsec decoding."); + } + const decoded = nip19.decode(trimmed); + if (!decoded || decoded.type !== "nsec" || !decoded.data) { + throw new Error("Invalid nsec key."); + } + const secretBytes = decoded.data; + const privateKeyHex = + typeof secretBytes === "string" + ? secretBytes + : Array.from(secretBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + if (!HEX64_REGEX.test(privateKeyHex)) { + throw new Error("Decoded nsec key is invalid."); + } + const pubkey = tools.getPublicKey(privateKeyHex); + return { privateKey: privateKeyHex, pubkey }; + } + + throw new Error("Unrecognized key format. Provide a hex private key or nsec string."); + } + + async registerPrivateKeySigner({ privateKey, pubkey, persist, passphrase, validator } = {}) { + if (!privateKey || typeof privateKey !== "string" || !HEX64_REGEX.test(privateKey)) { + throw new Error("A valid hex private key is required."); + } + + const normalizedPrivateKey = privateKey.toLowerCase(); + const normalizedPubkey = + typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim().toLowerCase() + : null; + + if (!normalizedPubkey || !HEX64_REGEX.test(normalizedPubkey)) { + throw new Error("A valid hex public key is required."); + } + + if (typeof validator === "function") { + validator(normalizedPubkey); + } + + const cipherClosures = await createPrivateKeyCipherClosures(normalizedPrivateKey); + + const adapter = { + type: "nsec", + pubkey: normalizedPubkey, + signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey), + ...cipherClosures, + }; + + this.setActiveSigner(adapter); + this.pubkey = normalizedPubkey; + + if (persist && passphrase) { + try { + const encrypted = await encryptSessionPrivateKey(normalizedPrivateKey, passphrase); + persistSessionActorEntry({ + pubkey: normalizedPubkey, + encrypted, + source: "nsec", + persisted: true, + }); + this.sessionActor = { pubkey: normalizedPubkey, source: "nsec", persisted: true }; + } catch (error) { + devLogger.warn("[nostr] Failed to persist private key signer:", error); + } + } + + return normalizedPubkey; + } } diff --git a/tests/nostr-signer-race.test.mjs b/tests/nostr-signer-race.test.mjs index 3cb34217..a43a07eb 100644 --- a/tests/nostr-signer-race.test.mjs +++ b/tests/nostr-signer-race.test.mjs @@ -15,7 +15,7 @@ test("ensureActiveSignerForPubkey prefers concurrent login over late extension i const client = new NostrClient(); client.pubkey = HEX_PUBKEY; // Pre-condition: Cache permissions to trigger the waitForNip07Extension logic - client.extensionPermissionCache.add("sign_event"); + client.extensionPermissionCache.set("sign_event", true); // Mock a NIP-46 signer that might be set during the race const mockNip46Signer = {