diff --git a/js/services/storageService.js b/js/services/storageService.js index aa742adc..e6bb3bad 100644 --- a/js/services/storageService.js +++ b/js/services/storageService.js @@ -153,6 +153,15 @@ function hexToBytes(hex) { return bytes; } +function createStorageUnlockError(code, message, cause) { + const error = new Error(message); + error.code = code; + if (cause) { + error.cause = cause; + } + return error; +} + /** * Service to manage encrypted connection storage using IndexedDB. * Connections are encrypted with a random Master Key (AES-GCM), @@ -336,6 +345,57 @@ export class StorageService { ); } + _normalizeUnlockDecryptError(error) { + if (error?.code === "storage-unlock-permission-denied") { + return error; + } + if (error?.code === "storage-unlock-no-decryptor") { + return error; + } + if (error?.code === "storage-unlock-decrypt-failed") { + return error; + } + + const rawCode = + typeof error?.code === "string" ? error.code.trim().toLowerCase() : ""; + const rawMessage = + typeof error?.message === "string" ? error.message.toLowerCase() : ""; + + if ( + rawCode === "extension-permission-denied" || + rawCode === "extension-encryption-permission-denied" || + rawCode.includes("permission-denied") || + rawMessage.includes("permission denied") || + rawMessage.includes("user rejected") + ) { + return createStorageUnlockError( + "storage-unlock-permission-denied", + "Signer permission denied while decrypting storage credentials.", + error, + ); + } + + if ( + rawCode === "nip04-missing" || + rawCode === "nip44-missing" || + rawCode.includes("no-decrypt") || + rawMessage.includes("does not support") || + rawMessage.includes("failed to decrypt master key") + ) { + return createStorageUnlockError( + "storage-unlock-no-decryptor", + "Signer does not expose a compatible decrypt method (NIP-04/NIP-44).", + error, + ); + } + + return createStorageUnlockError( + "storage-unlock-decrypt-failed", + "Unable to decrypt the encrypted storage key.", + error, + ); + } + /** * Encrypts a connection payload (secrets) using the session's Master Key. * Uses AES-GCM with a random 12-byte IV. @@ -404,7 +464,11 @@ export class StorageService { if (account && account.encryptedMasterKey) { // Decrypt existing key - masterKey = await this._decryptMasterKey(account.encryptedMasterKey, signer, pubkey); + try { + masterKey = await this._decryptMasterKey(account.encryptedMasterKey, signer, pubkey); + } catch (error) { + throw this._normalizeUnlockDecryptError(error); + } } else { // Create new key masterKey = await this._generateMasterKey(); diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 52fb62ac..1625cd83 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -23,6 +23,7 @@ import { } from "../utils/hashtagNormalization.js"; import { formatTimeAgo } from "../utils/formatters.js"; import { getActiveSigner } from "../nostr/client.js"; +import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../nostr/nip07Permissions.js"; import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { buildPublicUrl, buildR2Key } from "../r2.js"; import { buildProfileMetadataEvent } from "../nostrEventSchemas.js"; @@ -9984,8 +9985,16 @@ export class ProfileModalController { const isUnlocked = storageService && storageService.masterKeys.has(pubkey); if (this.storageStatusText) { - this.storageStatusText.textContent = isUnlocked ? "Unlocked" : "Locked"; - this.storageStatusText.className = isUnlocked ? "text-xs text-status-success" : "text-xs text-status-warning"; + if (isUnlocked) { + this.storageStatusText.textContent = "Unlocked"; + this.storageStatusText.className = "text-xs text-status-success"; + } else if (this.storageUnlockFailure?.message) { + this.storageStatusText.textContent = `Locked (${this.storageUnlockFailure.message})`; + this.storageStatusText.className = "text-xs text-status-danger"; + } else { + this.storageStatusText.textContent = "Locked"; + this.storageStatusText.className = "text-xs text-status-warning"; + } } if (isUnlocked) { @@ -10106,12 +10115,59 @@ export class ProfileModalController { } } + getStorageUnlockFailureMessage(error) { + const code = typeof error?.code === "string" ? error.code : ""; + + switch (code) { + case "storage-unlock-permission-denied": + return "Storage unlock requires extension encryption permission. Approve the prompt, then retry unlock."; + case "storage-unlock-no-decryptor": + return "Your signer cannot decrypt storage keys. Use a signer with NIP-44 or NIP-04 decrypt support."; + case "storage-unlock-decrypt-failed": + return "Unable to decrypt your saved storage key. Retry unlock and confirm the active account matches."; + default: + return typeof error?.message === "string" && error.message.trim() + ? error.message + : "Failed to unlock storage. Ensure your signer supports NIP-04/44."; + } + } + + setStorageUnlockFailureState(error) { + const code = typeof error?.code === "string" ? error.code : "storage-unlock-decrypt-failed"; + const message = this.getStorageUnlockFailureMessage(error); + + this.storageUnlockFailure = { code, message }; + + if (this.storageStatusText) { + this.storageStatusText.textContent = `Locked (${message})`; + this.storageStatusText.className = "text-xs text-status-danger"; + } + + this.setStorageFormStatus(message, "error"); + } + + clearStorageUnlockFailureState() { + this.storageUnlockFailure = null; + } + + async requestStorageUnlockPermissions() { + const client = this.services?.nostrClient; + if (!client || typeof client.ensureExtensionPermissions !== "function") { + return { ok: true }; + } + + return client.ensureExtensionPermissions(DEFAULT_NIP07_ENCRYPTION_METHODS, { + context: "storage-unlock", + statusMessage: + "Approve the extension prompt to allow storage encryption/decryption.", + showSpinner: true, + }); + } + async handleUnlockStorage() { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!pubkey) return; - // We need the active signer. Try the global first, then resolve via the - // nostr client which can detect/restore the NIP-07 extension signer. let signer = getActiveSigner(); if ( !signer && @@ -10152,6 +10208,55 @@ export class ProfileModalController { return; } + const signerType = typeof signer?.type === "string" ? signer.type.trim().toLowerCase() : ""; + const isExtensionSigner = signerType === "extension" || signerType === "nip07"; + + const shouldForcePermissionRetry = + this.storageUnlockBtn?.dataset?.retryAction === "request-permissions"; + + if (shouldForcePermissionRetry) { + const extensionPermissionCache = this.services?.nostrClient?.extensionPermissionCache; + if (extensionPermissionCache instanceof Set) { + for (const method of DEFAULT_NIP07_ENCRYPTION_METHODS) { + extensionPermissionCache.delete(method); + } + } + } + + if (isExtensionSigner) { + const permissionResult = await this.requestStorageUnlockPermissions(); + if (!permissionResult?.ok) { + const permissionError = permissionResult?.error || new Error("Extension permissions denied."); + permissionError.code = "storage-unlock-permission-denied"; + this.setStorageUnlockFailureState(permissionError); + this.showError(this.getStorageUnlockFailureMessage(permissionError)); + if (this.storageUnlockBtn) { + this.storageUnlockBtn.dataset.retryAction = "request-permissions"; + this.storageUnlockBtn.textContent = "Retry Permissions + Unlock"; + } + return; + } + } + + const caps = signer?.capabilities && typeof signer.capabilities === "object" + ? signer.capabilities + : null; + const hasNip44Decrypt = + caps?.nip44 !== false && typeof signer?.nip44Decrypt === "function"; + const hasNip04Decrypt = + caps?.nip04 !== false && + (typeof signer?.nip04Decrypt === "function" || typeof signer?.decrypt === "function"); + + if (!hasNip44Decrypt && !hasNip04Decrypt) { + const missingDecryptError = new Error( + "This signer cannot decrypt storage keys (NIP-44/NIP-04 missing).", + ); + missingDecryptError.code = "storage-unlock-no-decryptor"; + this.setStorageUnlockFailureState(missingDecryptError); + this.showError(this.getStorageUnlockFailureMessage(missingDecryptError)); + return; + } + if (this.storageUnlockBtn) { this.storageUnlockBtn.disabled = true; this.storageUnlockBtn.textContent = "Unlocking..."; @@ -10159,23 +10264,32 @@ export class ProfileModalController { try { await storageService.unlock(pubkey, { signer }); + this.clearStorageUnlockFailureState(); this.showSuccess("Storage unlocked."); this.populateStoragePane(); } catch (error) { devLogger.error("Failed to unlock storage:", error); - const message = - error && typeof error.message === "string" - ? error.message - : "Failed to unlock storage. Ensure your signer supports NIP-04/44."; - this.showError(message); + this.setStorageUnlockFailureState(error); + this.showError(this.getStorageUnlockFailureMessage(error)); + if (this.storageUnlockBtn) { + this.storageUnlockBtn.dataset.retryAction = "request-permissions"; + } } finally { if (this.storageUnlockBtn) { this.storageUnlockBtn.disabled = false; - this.storageUnlockBtn.textContent = "Unlock Storage"; + const shouldShowRetry = this.storageUnlockFailure?.code === "storage-unlock-permission-denied"; + if (shouldShowRetry) { + this.storageUnlockBtn.textContent = "Retry Permissions + Unlock"; + this.storageUnlockBtn.dataset.retryAction = "request-permissions"; + } else { + this.storageUnlockBtn.textContent = "Unlock Storage"; + delete this.storageUnlockBtn.dataset.retryAction; + } } } } + async handleSaveStorage() { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!pubkey) return; diff --git a/tests/storage-service.test.mjs b/tests/storage-service.test.mjs index 7e00daef..d1d69999 100644 --- a/tests/storage-service.test.mjs +++ b/tests/storage-service.test.mjs @@ -82,6 +82,54 @@ describe("StorageService", () => { assert.strictEqual(mockLegacySigner.nip04Encrypt.mock.callCount(), 1); }); + test("unlock() normalizes permission denied decrypt errors", async () => { + await storageService.unlock(pubkey, { signer: mockSigner }); + storageService.lock(pubkey); + + const permissionDeniedSigner = { + nip44Decrypt: mock.fn(async () => { + const error = new Error("Permission denied by extension"); + error.code = "extension-encryption-permission-denied"; + throw error; + }), + }; + + await assert.rejects( + async () => storageService.unlock(pubkey, { signer: permissionDeniedSigner }), + (error) => error?.code === "storage-unlock-permission-denied", + ); + }); + + test("unlock() normalizes missing decryptor errors", async () => { + await storageService.unlock(pubkey, { signer: mockSigner }); + storageService.lock(pubkey); + + const missingDecryptSigner = { + capabilities: { nip44: false, nip04: false }, + }; + + await assert.rejects( + async () => storageService.unlock(pubkey, { signer: missingDecryptSigner }), + (error) => error?.code === "storage-unlock-no-decryptor", + ); + }); + + test("unlock() normalizes unknown decrypt errors", async () => { + await storageService.unlock(pubkey, { signer: mockSigner }); + storageService.lock(pubkey); + + const failingDecryptSigner = { + nip44Decrypt: mock.fn(async () => { + throw new Error("Unexpected decrypt failure"); + }), + }; + + await assert.rejects( + async () => storageService.unlock(pubkey, { signer: failingDecryptSigner }), + (error) => error?.code === "storage-unlock-decrypt-failed", + ); + }); + test("saveConnection() encrypts and stores connection", async () => { await storageService.unlock(pubkey, { signer: mockSigner });