diff --git a/js/nostr/client.js b/js/nostr/client.js index 13bed5b9..accbd0be 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -42,6 +42,7 @@ import { normalizeStoragePointer, resolveStoragePointerValue, } from "../utils/storagePointer.js"; +import { RelayBatchFetcher } from "./relayBatchFetcher.js"; import { createWatchHistoryManager, normalizePointerInput, @@ -218,9 +219,8 @@ import { SignerManager, resolveSignerCapabilities, hydrateExtensionSignerCapabilities, - attachNipMethodAliases + attachNipMethodAliases, } from "./managers/SignerManager.js"; -import { RelayBatchFetcher } from "./relayBatchFetcher.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -257,12 +257,8 @@ function setActiveSigner(signer) { if (signer && typeof signer === "object") { hydrateExtensionSignerCapabilities(signer); attachNipMethodAliases(signer); - const capsDescriptor = Object.getOwnPropertyDescriptor( - signer, - "capabilities", - ); + const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities"); const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; - if (!isGetter) { signer.capabilities = resolveSignerCapabilities(signer); } @@ -800,6 +796,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; } @@ -2979,9 +2977,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 f361cdda..c6bebad2 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -48,6 +48,7 @@ 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"; @@ -263,13 +264,21 @@ export class SignerManager { this.extensionReady = false; this.extensionPermissionsGranted = false; this.extensionPermissionCache = new Map(); - const stored = readStoredNip07Permissions(); - if (stored && stored.size > 0) { - this.extensionPermissionsGranted = true; - for (const method of stored) { - this.extensionPermissionCache.set(method, true); + + // 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(); @@ -436,8 +445,9 @@ export class SignerManager { return { ok: true }; } + let extension; try { - await waitForNip07Extension(); + extension = await waitForNip07Extension(); } catch (error) { return { ok: false, error: "extension-missing" }; } @@ -552,7 +562,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, }; @@ -941,4 +953,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 = {