diff --git a/js/nostr/client.js b/js/nostr/client.js index 1084c6ac..cd3cd280 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -214,7 +214,13 @@ 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"; +import { RelayBatchFetcher } from "./relayBatchFetcher.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -230,10 +236,31 @@ function normalizeProfileFromEvent(event) { // We keep them as pass-throughs to the registry or utility logic where appropriate. function resolveActiveSigner(pubkey) { - return resolveActiveSignerFromRegistry(pubkey); + const signer = resolveActiveSignerFromRegistry(pubkey); + if (signer) { + hydrateExtensionSignerCapabilities(signer); + attachNipMethodAliases(signer); + if (!signer.capabilities && resolveSignerCapabilities) { + const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities"); + if (!capsDescriptor || typeof capsDescriptor.get !== "function") { + signer.capabilities = resolveSignerCapabilities(signer); + } + } + } + return signer; } function setActiveSigner(signer) { + if (signer) { + hydrateExtensionSignerCapabilities(signer); + attachNipMethodAliases(signer); + if (!signer.capabilities && resolveSignerCapabilities) { + const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities"); + if (!capsDescriptor || typeof capsDescriptor.get !== "function") { + signer.capabilities = resolveSignerCapabilities(signer); + } + } + } setActiveSignerInRegistry(signer); } @@ -580,6 +607,7 @@ export class NostrClient { constructor() { this.connectionManager = new ConnectionManager(this); this.signerManager = new SignerManager(this); + this.relayBatchFetcher = new RelayBatchFetcher(this); /** * @type {Map} @@ -752,6 +780,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; } @@ -892,6 +922,23 @@ export class NostrClient { } } + resolveEventDTag(event, fallbackEvent = null) { + if (!event || typeof event !== "object") { + if (fallbackEvent) { + return this.resolveEventDTag(fallbackEvent); + } + return ""; + } + const val = getDTagValueFromTags(event.tags); + if (val) { + return val; + } + if (fallbackEvent) { + return this.resolveEventDTag(fallbackEvent); + } + return ""; + } + getActiveKey(video) { return getActiveKey(video); } @@ -2911,6 +2958,21 @@ export class NostrClient { return this.signerManager.ensureActiveSignerForPubkey(pubkey); } + async registerPrivateKeySigner({ privateKey, pubkey }) { + if (!privateKey) { + throw new Error("Private key is required."); + } + const adapter = await createNsecAdapter({ privateKey, pubkey }); + this.signerManager.setActiveSigner(adapter); + this.sessionActor = { + pubkey: adapter.pubkey, + privateKey: privateKey, + source: "nsec", + createdAt: Date.now(), + }; + return adapter; + } + async loginWithExtension(options) { return this.signerManager.loginWithExtension(options); } @@ -2919,6 +2981,10 @@ export class NostrClient { return this.signerManager.connectRemoteSigner(params); } + installNip46Client(client) { + this.signerManager.installNip46Client(client); + } + async useStoredRemoteSigner(options) { return this.signerManager.useStoredRemoteSigner(options); } diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index e794c862..d1bfe179 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -47,13 +47,14 @@ import { summarizeUrlForLog, } from "../nip46LoggingUtils.js"; +import { bytesToHex } from "../../../vendor/crypto-helpers.bundle.min.js"; import { devLogger, userLogger } from "../../utils/logger.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,10 @@ export class SignerManager { this.extensionReady = false; this.extensionPermissionsGranted = false; this.extensionPermissionCache = new Map(); + const storedPermissions = readStoredNip07Permissions(); + for (const method of storedPermissions) { + this.extensionPermissionCache.set(method, true); + } this.sessionActorCipherClosures = null; this.sessionActorCipherClosuresPrivateKey = null; this.remoteSignerListeners = new Set(); @@ -311,21 +316,45 @@ export class SignerManager { async ensureSessionActor() { if (this.sessionActor) { - return this.sessionActor; + return this.sessionActor.pubkey; } if (this.lockedSessionActor) { - return this.lockedSessionActor; + return this.lockedSessionActor.pubkey; } const storedEntry = readStoredSessionActorEntry(); if (storedEntry) { this.lockedSessionActor = storedEntry; this.sessionActor = storedEntry; - return storedEntry; + return storedEntry.pubkey; } - return null; + const tools = await ensureNostrTools(); + let privateKey = ""; + if (tools && typeof tools.generateSecretKey === "function") { + const secret = tools.generateSecretKey(); + privateKey = bytesToHex(secret); + } else if (tools && typeof tools.generatePrivateKey === "function") { + privateKey = tools.generatePrivateKey(); + } else { + throw new Error("Failed to generate session actor: nostr-tools missing key generation."); + } + + let pubkey = ""; + if (tools && typeof tools.getPublicKey === "function") { + pubkey = tools.getPublicKey(privateKey); + } + + const newActor = { + pubkey, + privateKey, + source: "generated", + createdAt: Date.now(), + }; + + this.sessionActor = newActor; + return pubkey; } clearStoredSessionActor() { @@ -423,6 +452,9 @@ 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 }; } @@ -435,11 +467,14 @@ export class SignerManager { const message = resolvePermissionStatusMessage(missing, context); try { - const response = await requestEnablePermissions(missing); - if (response?.enabled) { + const response = await requestEnablePermissions(window.nostr, missing); + if (response?.ok) { writeStoredNip07Permissions(missing); this.extensionPermissionsGranted = true; this.extensionPermissionCache.set(cacheKey, true); + for (const method of missing) { + this.extensionPermissionCache.set(method, true); + } return { ok: true }; } return { ok: false, error: "permission-denied" }; @@ -547,6 +582,16 @@ export class SignerManager { return { pubkey: normalized, signer: adapter }; } + installNip46Client(client) { + if (!client) { + return; + } + this.nip46Client = client; + this.pubkey = client.userPubkey || client.remotePubkey; // Fallback + this.setActiveSigner(client.getActiveSigner()); + this.emitRemoteSignerChange(); + } + async connectRemoteSigner({ connectionString, remember = true, @@ -893,6 +938,10 @@ export class SignerManager { }; } + signEventWithPrivateKey(event, privateKey) { + return signEventWithPrivateKey(event, privateKey); + } + logout() { const previousPubkey = this.pubkey; this.pubkey = null; diff --git a/js/nostr/relayBatchFetcher.js b/js/nostr/relayBatchFetcher.js index 7afe346e..5fdf0910 100644 --- a/js/nostr/relayBatchFetcher.js +++ b/js/nostr/relayBatchFetcher.js @@ -5,6 +5,9 @@ import { normalizeNostrPubkey, sanitizeRelayList } from "./nip46Client.js"; import { DEFAULT_RELAY_URLS, RELAY_URLS } from "./toolkit.js"; export class RelayBatchFetcher { + /** + * @param {import("./client.js").NostrClient} client + */ constructor(client) { this.client = client; } diff --git a/js/nostr/sessionActor.js b/js/nostr/sessionActor.js index bbbafc2f..4088b37a 100644 --- a/js/nostr/sessionActor.js +++ b/js/nostr/sessionActor.js @@ -484,7 +484,17 @@ export function clearStoredSessionActor() { export function isSessionActor(nostrClient) { const sa = nostrClient?.sessionActor; - return !!sa && sa.source !== "nsec"; + if (!sa) { + return false; + } + if ( + nostrClient?.pubkey && + sa.pubkey && + nostrClient.pubkey !== sa.pubkey + ) { + return false; + } + return sa.source !== "nsec"; } function _closeSessionActorDb() { diff --git a/tests/nostr-login-permissions.test.mjs b/tests/nostr-login-permissions.test.mjs index 7d56fc2a..9451b3c8 100644 --- a/tests/nostr-login-permissions.test.mjs +++ b/tests/nostr-login-permissions.test.mjs @@ -55,6 +55,7 @@ function setupLoginEnvironment({ enableImpl, getPublicKey = HEX_PUBKEY } = {}) { return Promise.resolve(); }, getPublicKey: () => Promise.resolve(getPublicKey), + signEvent: () => Promise.resolve({}), }; windowRef.nostr = nostrStub; @@ -456,10 +457,11 @@ describe("NIP-07 Login Permissions", () => { it("NIP-07 login does not wait for deferred permission grants", async () => { clearStoredPermissions(); const env = setupLoginEnvironment(); - const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const target = nostrClient.signerManager || nostrClient; + const originalEnsurePermissions = target.ensureExtensionPermissions; let completionPromise; - nostrClient.ensureExtensionPermissions = async () => { + target.ensureExtensionPermissions = async () => { completionPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error("permission denied")), 300); }); @@ -482,7 +484,7 @@ describe("NIP-07 Login Permissions", () => { "deferred permission grants should still reject", ); } finally { - nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + target.ensureExtensionPermissions = originalEnsurePermissions; env.restore(); nostrClient.logout(); clearStoredPermissions(); diff --git a/tests/nostr-signer-race.test.mjs b/tests/nostr-signer-race.test.mjs index 3cb34217..a5d8e3bb 100644 --- a/tests/nostr-signer-race.test.mjs +++ b/tests/nostr-signer-race.test.mjs @@ -15,7 +15,11 @@ 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"); + if (typeof client.extensionPermissionCache.add === 'function') { + client.extensionPermissionCache.add("sign_event"); + } else if (typeof client.extensionPermissionCache.set === 'function') { + client.extensionPermissionCache.set("sign_event", true); + } // Mock a NIP-46 signer that might be set during the race const mockNip46Signer = {