Improve storage unlock permission and decrypt error handling

This commit is contained in:
thePR0M3TH3AN committed 2026-02-08 14:38:21 -05:00
1 parent a54e3c1329
commit 44dfcc363b
3 files changed
+237 -11

No files matched your search

+65 -1
View File
@@ -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();
+124 -10
View File
@@ -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;
+48
View File
@@ -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 });