diff --git a/js/nostr/client.js b/js/nostr/client.js index b753ccd7..6ea598e0 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -19,7 +19,6 @@ import { isDevMode } from "../config.js"; import { FEATURE_PUBLISH_NIP71 } from "../constants.js"; -import { accessControl } from "../accessControl.js"; import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js"; import { infoHashFromMagnet } from "../magnets.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable @@ -1900,6 +1899,157 @@ export class NostrClient { * 4. **Authorize**: Handles any `auth_url` challenges if the signer requires out-of-band approval. * 5. **Session**: Stores the session metadata locally (if `remember: true`) to allow auto-reconnect. */ + async loginWithExtension(options = {}) { + let extension = null; + let attempts = 0; + const maxAttempts = 3; + + // Optimistic check: if window.nostr is already there, use it immediately. + if (typeof window !== "undefined" && window.nostr) { + extension = window.nostr; + } else { + while (!extension && attempts < maxAttempts) { + try { + // Fast wait for the first attempt, slightly longer for retries + const timeout = attempts === 0 ? 1500 : 3000; + await waitForNip07Extension(timeout); + extension = window.nostr; + } catch (waitError) { + devLogger.log( + `Timed out waiting for extension injection (attempt ${ + attempts + 1 + }/${maxAttempts}):`, + waitError, + ); + } + + if (extension) { + break; + } + + attempts++; + if (attempts < maxAttempts) { + devLogger.log("Retrying NIP-07 detection..."); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + } + + if (!extension) { + devLogger.log("No Nostr extension found"); + throw new Error("Please install a Nostr extension (Alby, nos2x, etc.)."); + } + + const { allowAccountSelection = false, expectPubkey } = + typeof options === "object" && options !== null ? options : {}; + const normalizedExpectedPubkey = + typeof expectPubkey === "string" && expectPubkey.trim() + ? expectPubkey.trim().toLowerCase() + : null; + + if (typeof extension.getPublicKey !== "function") { + throw new Error( + "This NIP-07 extension is missing getPublicKey support. Please update the extension.", + ); + } + + const permissionResult = await this.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + if (!permissionResult.ok) { + const denialMessage = + 'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.'; + const denialError = new Error(denialMessage); + if (permissionResult.error) { + denialError.cause = permissionResult.error; + } + throw denialError; + } + + if ( + allowAccountSelection && + typeof extension.selectAccounts === "function" + ) { + try { + const selection = await runNip07WithRetry( + () => + extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined), + { label: "extension.selectAccounts" }, + ); + + const didCancelSelection = + selection === undefined || + selection === null || + selection === false || + (Array.isArray(selection) && selection.length === 0); + + if (didCancelSelection) { + throw new Error("Account selection was cancelled."); + } + } catch (selectionErr) { + const message = + selectionErr && typeof selectionErr.message === "string" + ? selectionErr.message + : "Account selection was cancelled."; + throw new Error(message); + } + } + + const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), { + label: "extension.getPublicKey", + }); + + if (!pubkey || typeof pubkey !== "string") { + throw new Error( + "The NIP-07 extension did not return a public key. Please try again.", + ); + } + + if ( + normalizedExpectedPubkey && + pubkey.toLowerCase() !== normalizedExpectedPubkey + ) { + throw new Error( + "The selected account doesn't match the expected profile. Please try again.", + ); + } + + if (typeof options.validator === "function") { + try { + const isValid = await options.validator(pubkey); + if (!isValid) { + throw new Error("Access denied."); + } + } catch (validationError) { + const message = + validationError instanceof Error + ? validationError.message + : "Access denied."; + const error = new Error(message); + error.code = "access-denied"; + if (validationError instanceof Error) { + error.cause = validationError; + } + throw error; + } + } + + const adapter = await createNip07Adapter(extension); + adapter.pubkey = pubkey; + setActiveSigner(adapter); + + this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS).catch( + (err) => { + userLogger.warn( + "[nostr] Extension permissions were not fully granted after login:", + err, + ); + }, + ); + + return { pubkey, signer: adapter }; + } + async connectRemoteSigner({ connectionString, remember = true, @@ -2201,6 +2351,25 @@ export class NostrClient { remotePubkey: summarizeHexForLog(remotePubkey), userPubkey: summarizeHexForLog(userPubkey), }); + + if (typeof options?.validator === "function") { + try { + const isValid = await options.validator(userPubkey); + if (!isValid) { + throw new Error("Access denied."); + } + } catch (validationError) { + const message = + validationError instanceof Error + ? validationError.message + : "Access denied."; + const error = new Error(message); + error.code = "access-denied"; + client.destroy(); // Cleanup client since we won't use it + throw error; + } + } + client.metadata = metadata; const signer = await this.installNip46Client(client, { userPubkey }); @@ -2296,6 +2465,7 @@ export class NostrClient { options && typeof options === "object" ? options : {}; const silent = normalizedOptions.silent === true; const forgetOnError = normalizedOptions.forgetOnError === true; + const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null; const stored = readStoredNip46Session(); if (!stored) { @@ -2344,6 +2514,25 @@ export class NostrClient { await client.ensureSubscription(); await client.connect({ permissions: stored.permissions }); const userPubkey = await client.getUserPubkey(); + + if (validator) { + try { + const isValid = await validator(userPubkey); + if (!isValid) { + throw new Error("Access denied."); + } + } catch (validationError) { + const message = + validationError instanceof Error + ? validationError.message + : "Access denied."; + const error = new Error(message); + error.code = "access-denied"; + client.destroy(); + throw error; + } + } + client.metadata = stored.metadata; const signer = await this.installNip46Client(client, { userPubkey }); @@ -3122,6 +3311,7 @@ export class NostrClient { pubkey, persist = false, passphrase, + validator, } = {}) { const normalizedPrivateKey = typeof privateKey === "string" && HEX64_REGEX.test(privateKey) @@ -3158,6 +3348,23 @@ export class NostrClient { throw new Error("Unable to resolve the public key for this private key."); } + if (typeof validator === "function") { + try { + const isValid = await validator(normalizedPubkey); + if (!isValid) { + throw new Error("Access denied."); + } + } catch (validationError) { + const message = + validationError instanceof Error + ? validationError.message + : "Access denied."; + const error = new Error(message); + error.code = "access-denied"; + throw error; + } + } + this.sessionActor = { pubkey: normalizedPubkey, privateKey: normalizedPrivateKey, diff --git a/js/services/authProviders/nip07.js b/js/services/authProviders/nip07.js index 3b16e42d..1d425d2d 100644 --- a/js/services/authProviders/nip07.js +++ b/js/services/authProviders/nip07.js @@ -1,14 +1,4 @@ -import { - waitForNip07Extension, - runNip07WithRetry, - requestEnablePermissions, - DEFAULT_NIP07_PERMISSION_METHODS, -} from "../../nostr/nip07Permissions.js"; -import { createNip07Adapter } from "../../nostr/adapters/nip07Adapter.js"; -import { setActiveSigner } from "../../nostrClientRegistry.js"; -import { ensureNostrTools } from "../../nostr/toolkit.js"; import { accessControl } from "../../accessControl.js"; -import { devLogger, userLogger } from "../../utils/logger.js"; const PROVIDER_ID = "nip07"; const PROVIDER_LABEL = "extension (nip-07)"; @@ -55,180 +45,6 @@ function normalizePubkey(result) { return ""; } -async function performNip07Login(options = {}, nostrClient = null) { - let extension = null; - let attempts = 0; - const maxAttempts = 3; - - // Optimistic check: if window.nostr is already there, use it immediately. - if (typeof window !== "undefined" && window.nostr) { - extension = window.nostr; - } else { - while (!extension && attempts < maxAttempts) { - try { - // Fast wait for the first attempt, slightly longer for retries - const timeout = attempts === 0 ? 1500 : 3000; - await waitForNip07Extension(timeout); - extension = window.nostr; - } catch (waitError) { - devLogger.log( - `Timed out waiting for extension injection (attempt ${ - attempts + 1 - }/${maxAttempts}):`, - waitError, - ); - } - - if (extension) { - break; - } - - attempts++; - if (attempts < maxAttempts) { - devLogger.log("Retrying NIP-07 detection..."); - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } - } - - if (!extension) { - devLogger.log("No Nostr extension found"); - throw new Error( - "Please install a Nostr extension (Alby, nos2x, etc.)." - ); - } - - const { allowAccountSelection = false, expectPubkey } = - typeof options === "object" && options !== null ? options : {}; - const normalizedExpectedPubkey = - typeof expectPubkey === "string" && expectPubkey.trim() - ? expectPubkey.trim().toLowerCase() - : null; - - if (typeof extension.getPublicKey !== "function") { - throw new Error( - "This NIP-07 extension is missing getPublicKey support. Please update the extension." - ); - } - - // Use the client instance if available to ensure permission cache is updated - if (nostrClient && typeof nostrClient.ensureExtensionPermissions === "function") { - const permissionResult = await nostrClient.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, - ); - if (!permissionResult.ok) { - const denialMessage = - 'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.'; - const denialError = new Error(denialMessage); - if (permissionResult.error) { - denialError.cause = permissionResult.error; - } - throw denialError; - } - } else { - const permissionResult = await requestEnablePermissions( - extension, - DEFAULT_NIP07_PERMISSION_METHODS, - ); - if (!permissionResult.ok) { - const denialMessage = - 'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.'; - const denialError = new Error(denialMessage); - if (permissionResult.error) { - denialError.cause = permissionResult.error; - } - throw denialError; - } - } - - if (allowAccountSelection && typeof extension.selectAccounts === "function") { - try { - const selection = await runNip07WithRetry( - () => extension.selectAccounts(expectPubkey ? [expectPubkey] : undefined), - { label: "extension.selectAccounts" } - ); - - const didCancelSelection = - selection === undefined || - selection === null || - selection === false || - (Array.isArray(selection) && selection.length === 0); - - if (didCancelSelection) { - throw new Error("Account selection was cancelled."); - } - } catch (selectionErr) { - const message = - selectionErr && typeof selectionErr.message === "string" - ? selectionErr.message - : "Account selection was cancelled."; - throw new Error(message); - } - } - - const pubkey = await runNip07WithRetry(() => extension.getPublicKey(), { - label: "extension.getPublicKey", - }); - - if (!pubkey || typeof pubkey !== "string") { - throw new Error( - "The NIP-07 extension did not return a public key. Please try again." - ); - } - - if ( - normalizedExpectedPubkey && - pubkey.toLowerCase() !== normalizedExpectedPubkey - ) { - throw new Error( - "The selected account doesn't match the expected profile. Please try again." - ); - } - - const nip19Tools = await ensureNostrTools(); - const npubEncode = nip19Tools?.nip19?.npubEncode; - if (typeof npubEncode !== "function") { - throw new Error("NostrTools nip19 encoder is unavailable."); - } - const npub = npubEncode(pubkey); - - devLogger.log("Got pubkey:", pubkey); - devLogger.log("Converted to npub:", npub); - - // Access control - if (!accessControl.canAccess(npub)) { - if (accessControl.isBlacklisted(npub)) { - throw new Error("Your account has been blocked on this platform."); - } else { - throw new Error("Access restricted to admins and moderators users only."); - } - } - - const adapter = await createNip07Adapter(extension); - adapter.pubkey = pubkey; - setActiveSigner(adapter); - - // Ensure full permissions are requested post-login (e.g. encryption) - // This is non-blocking to avoid stalling if the user already approved - if (nostrClient && typeof nostrClient.ensureExtensionPermissions === "function") { - nostrClient.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS).catch((err) => { - userLogger.warn( - "[nostr] Extension permissions were not fully granted after login:", - err - ); - }); - } else { - requestEnablePermissions(extension, DEFAULT_NIP07_PERMISSION_METHODS).catch((err) => { - userLogger.warn( - "[nostr] Extension permissions were not fully granted after login:", - err - ); - }); - } - - return { pubkey, signer: adapter }; -} - export default { id: PROVIDER_ID, label: PROVIDER_LABEL, @@ -241,7 +57,23 @@ export default { messages: PROVIDER_MESSAGES, errorMessage: PROVIDER_MESSAGES.error, async login({ nostrClient, options } = {}) { - const result = await performNip07Login(options, nostrClient); + if (!nostrClient || typeof nostrClient.loginWithExtension !== "function") { + const error = new Error("Extension login is not available in this environment."); + error.code = "provider-unavailable"; + throw error; + } + + const validator = (pubkey) => { + if (!accessControl.canAccess(pubkey)) { + if (accessControl.isBlacklisted(pubkey)) { + throw new Error("Your account has been blocked on this platform."); + } + throw new Error("Access restricted to admins and moderators users only."); + } + return true; + }; + + const result = await nostrClient.loginWithExtension({ ...options, validator }); const pubkey = normalizePubkey(result); const signer = result && typeof result === "object" && result.signer diff --git a/js/services/authProviders/nip46.js b/js/services/authProviders/nip46.js index 091a830a..f6108a05 100644 --- a/js/services/authProviders/nip46.js +++ b/js/services/authProviders/nip46.js @@ -1,4 +1,5 @@ import { devLogger } from "../../utils/logger.js"; +import { accessControl } from "../../accessControl.js"; function summarizeHexForLog(value) { if (typeof value !== "string" || !value.trim()) { @@ -154,6 +155,16 @@ export default { }); let result = null; + const validator = (pubkey) => { + if (!accessControl.canAccess(pubkey)) { + if (accessControl.isBlacklisted(pubkey)) { + throw new Error("Your account has been blocked on this platform."); + } + throw new Error("Access restricted to admins and moderators users only."); + } + return true; + }; + if (normalized.reuseStored) { if (typeof nostrClient.useStoredRemoteSigner !== "function") { const error = new Error("No stored remote signer is available on this device."); @@ -162,7 +173,15 @@ export default { } devLogger.debug("[nip46] Reusing stored remote signer session"); - result = await nostrClient.useStoredRemoteSigner(); + // useStoredRemoteSigner should ideally accept a validator too, but if it restores a session, + // it might not expose a validator hook easily if not updated. + // Let's check NostrClient.useStoredRemoteSigner in client.js. + // It DOES NOT take a validator in my previous update (I missed it). + // I should update client.js to add validator to useStoredRemoteSigner too. + // For now, I will assume I will fix client.js later or accept that stored sessions are trusted? + // No, I must fix useStoredRemoteSigner too. + + result = await nostrClient.useStoredRemoteSigner({ validator }); devLogger.debug("[nip46] Stored remote signer returned", { pubkey: summarizeHexForLog(normalizePubkey(result)), }); @@ -184,6 +203,7 @@ export default { remember: normalized.remember, onAuthUrl: normalized.onAuthUrl, onStatus: normalized.onStatus, + validator, }); devLogger.debug("[nip46] Manual connect completed", { pubkey: summarizeHexForLog(normalizePubkey(result)), @@ -243,6 +263,7 @@ export default { onAuthUrl: normalized.onAuthUrl, onStatus: normalized.onStatus, handshakeTimeoutMs: normalized.handshakeTimeoutMs, + validator, }); if (result && typeof result === "object") { diff --git a/js/services/authProviders/nsec.js b/js/services/authProviders/nsec.js index bdca2caa..5d9f6e81 100644 --- a/js/services/authProviders/nsec.js +++ b/js/services/authProviders/nsec.js @@ -1,3 +1,5 @@ +import { accessControl } from "../../accessControl.js"; + const PROVIDER_ID = "nsec"; const PROVIDER_LABEL = "nsec or seed (direct private key)"; const PROVIDER_DESCRIPTION = @@ -131,11 +133,22 @@ export default { throw error; } + const validator = (pubkey) => { + if (!accessControl.canAccess(pubkey)) { + if (accessControl.isBlacklisted(pubkey)) { + throw new Error("Your account has been blocked on this platform."); + } + throw new Error("Access restricted to admins and moderators users only."); + } + return true; + }; + const registrationResult = await nostrClient.registerPrivateKeySigner({ privateKey, pubkey, persist: shouldPersist, passphrase: shouldPersist ? passphrase : undefined, + validator, }); const registeredPubkey = normalizePubkey(registrationResult) || pubkey; diff --git a/scripts/agent/test-nip07-refactor.mjs b/scripts/agent/test-nip07-refactor.mjs new file mode 100644 index 00000000..7896ec1f --- /dev/null +++ b/scripts/agent/test-nip07-refactor.mjs @@ -0,0 +1,70 @@ +import './setup-test-env.js'; +import { NostrClient, getActiveSigner } from '../../js/nostr/client.js'; +// import { userLogger, devLogger } from '../../js/utils/logger.js'; + +// Polyfill window.nostr +const mockPubkey = "0000000000000000000000000000000000000000000000000000000000000001"; +const mockNostr = { + getPublicKey: async () => mockPubkey, + signEvent: async (e) => { + e.sig = "mock-sig"; + e.id = "mock-id"; + return e; + }, + nip04: { + encrypt: async () => "mock-ciphertext", + decrypt: async () => "mock-plaintext" + } +}; + +global.window.nostr = mockNostr; + +async function run() { + console.log("Starting NIP-07 Refactor Verification..."); + + const client = new NostrClient(); + // Skip client.init() to avoid network/relay connection attempts in this isolated test + // await client.init(); + + console.log("Testing loginWithExtension..."); + try { + const result = await client.loginWithExtension(); + console.log("Login returned:", { pubkey: result.pubkey, hasSigner: !!result.signer }); + + if (result.pubkey !== mockPubkey) { + throw new Error(`Pubkey mismatch: expected ${mockPubkey}, got ${result.pubkey}`); + } + + const activeSigner = getActiveSigner(); + if (!activeSigner) { + throw new Error("Active signer was not set!"); + } + + if (activeSigner.pubkey !== mockPubkey) { + throw new Error(`Active signer pubkey mismatch: ${activeSigner.pubkey}`); + } + + console.log("Testing validator failure..."); + try { + await client.loginWithExtension({ + validator: async () => false // should fail + }); + console.error("Validator failure test FAILED: Should have thrown error"); + process.exit(1); + } catch (error) { + if (error.message.includes("Access denied")) { + console.log("Validator failure test SUCCESS!"); + } else { + console.error("Validator failure test FAILED: Wrong error", error); + process.exit(1); + } + } + + console.log("Verification SUCCESS!"); + } catch (error) { + console.error("Verification FAILED:", error); + process.exit(1); + } +} + +run();