diff --git a/package.json b/package.json
index f7b7091..b241e07 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
- "version": "v1.0.9",
+ "version": "v1.0.10",
"custom": {
"chrome": {
- "version": "v1.0.9"
+ "version": "v1.0.10"
},
"firefox": {
- "version": "v1.0.9"
+ "version": "v1.0.10"
}
},
"scripts": {
diff --git a/projects/chrome/custom-webpack.config.ts b/projects/chrome/custom-webpack.config.ts
index 98eabb7..aa81718 100644
--- a/projects/chrome/custom-webpack.config.ts
+++ b/projects/chrome/custom-webpack.config.ts
@@ -22,5 +22,9 @@ module.exports = {
import: 'src/options.ts',
runtime: false,
},
+ unlock: {
+ import: 'src/unlock.ts',
+ runtime: false,
+ },
},
} as Configuration;
diff --git a/projects/chrome/public/manifest.json b/projects/chrome/public/manifest.json
index c493e03..3621975 100644
--- a/projects/chrome/public/manifest.json
+++ b/projects/chrome/public/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
- "version": "1.0.9",
+ "version": "1.0.10",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
diff --git a/projects/chrome/public/unlock.html b/projects/chrome/public/unlock.html
new file mode 100644
index 0000000..8fdb89a
--- /dev/null
+++ b/projects/chrome/public/unlock.html
@@ -0,0 +1,245 @@
+
+
+
+
+ Plebeian Signer - Unlock
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+ is requesting access
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Invalid password
+
+
+
+
+
diff --git a/projects/chrome/src/background-common.ts b/projects/chrome/src/background-common.ts
index 3a07a47..1ea5c67 100644
--- a/projects/chrome/src/background-common.ts
+++ b/projects/chrome/src/background-common.ts
@@ -6,16 +6,38 @@ import {
CryptoHelper,
SignerMetaData,
Identity_DECRYPTED,
+ Identity_ENCRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
+ Relay_DECRYPTED,
+ Relay_ENCRYPTED,
+ NwcConnection_DECRYPTED,
+ NwcConnection_ENCRYPTED,
+ CashuMint_DECRYPTED,
+ CashuMint_ENCRYPTED,
+ deriveKeyArgon2,
} from '@common';
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { Buffer } from 'buffer';
+// Unlock request/response message types
+export interface UnlockRequestMessage {
+ type: 'unlock-request';
+ id: string;
+ password: string;
+}
+
+export interface UnlockResponseMessage {
+ type: 'unlock-response';
+ id: string;
+ success: boolean;
+ error?: string;
+}
+
export const debug = function (message: any) {
const dateString = new Date().toISOString();
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
@@ -372,3 +394,352 @@ const encrypt = async function (
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};
+
+// ==========================================
+// Unlock Vault Logic (for background script)
+// ==========================================
+
+/**
+ * Decrypt a value using AES-GCM with pre-derived key (v2)
+ */
+async function decryptV2(
+ encryptedBase64: string,
+ ivBase64: string,
+ keyBase64: string
+): Promise {
+ const keyBytes = Buffer.from(keyBase64, 'base64');
+ const iv = Buffer.from(ivBase64, 'base64');
+ const cipherText = Buffer.from(encryptedBase64, 'base64');
+
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ keyBytes,
+ { name: 'AES-GCM' },
+ false,
+ ['decrypt']
+ );
+
+ const decrypted = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv },
+ key,
+ cipherText
+ );
+
+ return new TextDecoder().decode(decrypted);
+}
+
+/**
+ * Decrypt a value using PBKDF2 (v1)
+ */
+async function decryptV1(
+ encryptedBase64: string,
+ ivBase64: string,
+ password: string
+): Promise {
+ return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
+}
+
+/**
+ * Generic decrypt function that handles both v1 and v2
+ */
+async function decryptValue(
+ encrypted: string,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ if (isV2) {
+ return decryptV2(encrypted, iv, keyOrPassword);
+ }
+ return decryptV1(encrypted, iv, keyOrPassword);
+}
+
+/**
+ * Parse decrypted value to the desired type
+ */
+function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
+ switch (type) {
+ case 'number':
+ return parseInt(value);
+ case 'boolean':
+ return value === 'true';
+ default:
+ return value;
+ }
+}
+
+/**
+ * Decrypt an identity
+ */
+async function decryptIdentity(
+ identity: Identity_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ return {
+ id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
+ nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
+ createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
+ privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
+ };
+}
+
+/**
+ * Decrypt a permission
+ */
+async function decryptPermission(
+ permission: Permission_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ const decrypted: Permission_DECRYPTED = {
+ id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
+ identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
+ host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
+ method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
+ methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
+ };
+ if (permission.kind) {
+ decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
+ }
+ return decrypted;
+}
+
+/**
+ * Decrypt a relay
+ */
+async function decryptRelay(
+ relay: Relay_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ return {
+ id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
+ identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
+ url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
+ read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
+ write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
+ };
+}
+
+/**
+ * Decrypt an NWC connection
+ */
+async function decryptNwcConnection(
+ nwc: NwcConnection_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ const decrypted: NwcConnection_DECRYPTED = {
+ id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
+ name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
+ connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
+ walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
+ relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
+ secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
+ createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
+ };
+ if (nwc.lud16) {
+ decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
+ }
+ if (nwc.cachedBalance) {
+ decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
+ }
+ if (nwc.cachedBalanceAt) {
+ decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
+ }
+ return decrypted;
+}
+
+/**
+ * Decrypt a Cashu mint
+ */
+async function decryptCashuMint(
+ mint: CashuMint_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
+ const decrypted: CashuMint_DECRYPTED = {
+ id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
+ name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
+ mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
+ unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
+ createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
+ proofs: JSON.parse(proofsJson),
+ };
+ if (mint.cachedBalance) {
+ decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
+ }
+ if (mint.cachedBalanceAt) {
+ decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
+ }
+ return decrypted;
+}
+
+/**
+ * Handle an unlock request from the unlock popup
+ */
+export async function handleUnlockRequest(
+ password: string
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ debug('handleUnlockRequest: Starting unlock...');
+
+ // Check if already unlocked
+ const existingSession = await getBrowserSessionData();
+ if (existingSession) {
+ debug('handleUnlockRequest: Already unlocked');
+ return { success: true };
+ }
+
+ // Get sync data
+ const browserSyncData = await getBrowserSyncData();
+ if (!browserSyncData) {
+ return { success: false, error: 'No vault data found' };
+ }
+
+ // Verify password
+ const passwordHash = await CryptoHelper.hash(password);
+ if (passwordHash !== browserSyncData.vaultHash) {
+ return { success: false, error: 'Invalid password' };
+ }
+ debug('handleUnlockRequest: Password verified');
+
+ // Detect vault version
+ const isV2 = !!browserSyncData.salt;
+ debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
+
+ let keyOrPassword: string;
+ let vaultKey: string | undefined;
+ let vaultPassword: string | undefined;
+
+ if (isV2) {
+ // v2: Derive key with Argon2id (~3 seconds)
+ debug('handleUnlockRequest: Deriving Argon2id key...');
+ const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
+ const keyBytes = await deriveKeyArgon2(password, saltBytes);
+ vaultKey = Buffer.from(keyBytes).toString('base64');
+ keyOrPassword = vaultKey;
+ debug('handleUnlockRequest: Key derived');
+ } else {
+ // v1: Use password directly
+ vaultPassword = password;
+ keyOrPassword = password;
+ }
+
+ // Decrypt identities
+ debug('handleUnlockRequest: Decrypting identities...');
+ const decryptedIdentities: Identity_DECRYPTED[] = [];
+ for (const identity of browserSyncData.identities) {
+ const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedIdentities.push(decrypted);
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
+
+ // Decrypt permissions
+ debug('handleUnlockRequest: Decrypting permissions...');
+ const decryptedPermissions: Permission_DECRYPTED[] = [];
+ for (const permission of browserSyncData.permissions) {
+ try {
+ const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedPermissions.push(decrypted);
+ } catch (e) {
+ debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
+ }
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
+
+ // Decrypt relays
+ debug('handleUnlockRequest: Decrypting relays...');
+ const decryptedRelays: Relay_DECRYPTED[] = [];
+ for (const relay of browserSyncData.relays) {
+ const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedRelays.push(decrypted);
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
+
+ // Decrypt NWC connections
+ debug('handleUnlockRequest: Decrypting NWC connections...');
+ const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
+ for (const nwc of browserSyncData.nwcConnections ?? []) {
+ const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedNwcConnections.push(decrypted);
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
+
+ // Decrypt Cashu mints
+ debug('handleUnlockRequest: Decrypting Cashu mints...');
+ const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
+ for (const mint of browserSyncData.cashuMints ?? []) {
+ const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedCashuMints.push(decrypted);
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
+
+ // Decrypt selectedIdentityId
+ let decryptedSelectedIdentityId: string | null = null;
+ if (browserSyncData.selectedIdentityId !== null) {
+ decryptedSelectedIdentityId = await decryptValue(
+ browserSyncData.selectedIdentityId,
+ browserSyncData.iv,
+ keyOrPassword,
+ isV2
+ );
+ }
+ debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
+
+ // Build session data
+ const browserSessionData: BrowserSessionData = {
+ vaultPassword: isV2 ? undefined : vaultPassword,
+ vaultKey: isV2 ? vaultKey : undefined,
+ iv: browserSyncData.iv,
+ salt: browserSyncData.salt,
+ permissions: decryptedPermissions,
+ identities: decryptedIdentities,
+ selectedIdentityId: decryptedSelectedIdentityId,
+ relays: decryptedRelays,
+ nwcConnections: decryptedNwcConnections,
+ cashuMints: decryptedCashuMints,
+ };
+
+ // Save session data
+ debug('handleUnlockRequest: Saving session data...');
+ await chrome.storage.session.set(browserSessionData);
+ debug('handleUnlockRequest: Unlock complete!');
+
+ return { success: true };
+ } catch (error: any) {
+ debug(`handleUnlockRequest: Error: ${error.message}`);
+ return { success: false, error: error.message || 'Unlock failed' };
+ }
+}
+
+/**
+ * Open the unlock popup window
+ */
+export async function openUnlockPopup(host?: string): Promise {
+ const width = 375;
+ const height = 500;
+ const { top, left } = await getPosition(width, height);
+
+ const id = crypto.randomUUID();
+ let url = `unlock.html?id=${id}`;
+ if (host) {
+ url += `&host=${encodeURIComponent(host)}`;
+ }
+
+ await chrome.windows.create({
+ type: 'popup',
+ url,
+ height,
+ width,
+ top,
+ left,
+ });
+}
diff --git a/projects/chrome/src/background.ts b/projects/chrome/src/background.ts
index 4433a1b..1c658c8 100644
--- a/projects/chrome/src/background.ts
+++ b/projects/chrome/src/background.ts
@@ -10,15 +10,19 @@ import {
debug,
getBrowserSessionData,
getPosition,
+ handleUnlockRequest,
nip04Decrypt,
nip04Encrypt,
nip44Decrypt,
nip44Encrypt,
+ openUnlockPopup,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
+ UnlockRequestMessage,
+ UnlockResponseMessage,
} from './background-common';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
@@ -33,8 +37,49 @@ const openPrompts = new Map<
}
>();
+// Track if unlock popup is already open
+let unlockPopupOpen = false;
+
+// Queue of pending NIP-07 requests waiting for unlock
+const pendingRequests: {
+ request: BackgroundRequestMessage;
+ resolve: (result: any) => void;
+ reject: (error: any) => void;
+}[] = [];
+
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
debug('Message received');
+
+ // Handle unlock request from unlock popup
+ if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
+ const unlockReq = message as UnlockRequestMessage;
+ debug('Processing unlock request');
+ const result = await handleUnlockRequest(unlockReq.password);
+ const response: UnlockResponseMessage = {
+ type: 'unlock-response',
+ id: unlockReq.id,
+ success: result.success,
+ error: result.error,
+ };
+
+ if (result.success) {
+ unlockPopupOpen = false;
+ // Process any pending NIP-07 requests
+ debug(`Processing ${pendingRequests.length} pending requests`);
+ while (pendingRequests.length > 0) {
+ const pending = pendingRequests.shift()!;
+ try {
+ const pendingResult = await processNip07Request(pending.request);
+ pending.resolve(pendingResult);
+ } catch (error) {
+ pending.reject(error);
+ }
+ }
+ }
+
+ return response;
+ }
+
const request = message as BackgroundRequestMessage | PromptResponseMessage;
debug(request);
@@ -55,6 +100,32 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
const browserSessionData = await getBrowserSessionData();
+ if (!browserSessionData) {
+ // Vault is locked - open unlock popup and queue the request
+ const req = request as BackgroundRequestMessage;
+ debug('Vault locked, opening unlock popup');
+
+ if (!unlockPopupOpen) {
+ unlockPopupOpen = true;
+ await openUnlockPopup(req.host);
+ }
+
+ // Queue this request to be processed after unlock
+ return new Promise((resolve, reject) => {
+ pendingRequests.push({ request: req, resolve, reject });
+ });
+ }
+
+ // Process the NIP-07 request
+ return processNip07Request(request as BackgroundRequestMessage);
+});
+
+/**
+ * Process a NIP-07 request after vault is unlocked
+ */
+async function processNip07Request(req: BackgroundRequestMessage): Promise {
+ const browserSessionData = await getBrowserSessionData();
+
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
@@ -67,8 +138,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
throw new Error('No Nostr identity available at endpoint.');
}
- const req = request as BackgroundRequestMessage;
-
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
@@ -212,4 +281,4 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
default:
throw new Error(`Not supported request method '${req.method}'.`);
}
-});
+}
diff --git a/projects/chrome/src/unlock.ts b/projects/chrome/src/unlock.ts
new file mode 100644
index 0000000..81ba8cf
--- /dev/null
+++ b/projects/chrome/src/unlock.ts
@@ -0,0 +1,106 @@
+import browser from 'webextension-polyfill';
+
+export interface UnlockRequestMessage {
+ type: 'unlock-request';
+ id: string;
+ password: string;
+}
+
+export interface UnlockResponseMessage {
+ type: 'unlock-response';
+ id: string;
+ success: boolean;
+ error?: string;
+}
+
+const params = new URLSearchParams(location.search);
+const id = params.get('id') as string;
+const host = params.get('host');
+
+// Elements
+const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
+const togglePasswordBtn = document.getElementById('togglePassword');
+const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
+const derivingOverlay = document.getElementById('derivingOverlay');
+const errorAlert = document.getElementById('errorAlert');
+const errorMessage = document.getElementById('errorMessage');
+const hostInfo = document.getElementById('hostInfo');
+const hostSpan = document.getElementById('hostSpan');
+
+// Show host info if available
+if (host && hostInfo && hostSpan) {
+ hostSpan.innerText = host;
+ hostInfo.classList.remove('hidden');
+}
+
+// Toggle password visibility
+togglePasswordBtn?.addEventListener('click', () => {
+ if (passwordInput.type === 'password') {
+ passwordInput.type = 'text';
+ togglePasswordBtn.innerHTML = '';
+ } else {
+ passwordInput.type = 'password';
+ togglePasswordBtn.innerHTML = '';
+ }
+});
+
+// Enable/disable unlock button based on password input
+passwordInput?.addEventListener('input', () => {
+ unlockBtn.disabled = !passwordInput.value;
+});
+
+// Handle enter key
+passwordInput?.addEventListener('keyup', (e) => {
+ if (e.key === 'Enter' && passwordInput.value) {
+ attemptUnlock();
+ }
+});
+
+// Handle unlock button click
+unlockBtn?.addEventListener('click', attemptUnlock);
+
+async function attemptUnlock() {
+ if (!passwordInput?.value) return;
+
+ // Show deriving overlay
+ derivingOverlay?.classList.remove('hidden');
+ errorAlert?.classList.add('hidden');
+
+ const message: UnlockRequestMessage = {
+ type: 'unlock-request',
+ id,
+ password: passwordInput.value,
+ };
+
+ try {
+ const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
+
+ if (response.success) {
+ // Success - close the window
+ window.close();
+ } else {
+ // Failed - show error
+ derivingOverlay?.classList.add('hidden');
+ showError(response.error || 'Invalid password');
+ }
+ } catch (error) {
+ console.error('Failed to send unlock message:', error);
+ derivingOverlay?.classList.add('hidden');
+ showError('Failed to unlock vault');
+ }
+}
+
+function showError(message: string) {
+ if (errorAlert && errorMessage) {
+ errorMessage.innerText = message;
+ errorAlert.classList.remove('hidden');
+ setTimeout(() => {
+ errorAlert.classList.add('hidden');
+ }, 3000);
+ }
+}
+
+// Focus password input on load
+document.addEventListener('DOMContentLoaded', () => {
+ passwordInput?.focus();
+});
diff --git a/projects/chrome/tsconfig.app.json b/projects/chrome/tsconfig.app.json
index 8401005..da6988e 100644
--- a/projects/chrome/tsconfig.app.json
+++ b/projects/chrome/tsconfig.app.json
@@ -12,7 +12,8 @@
"src/plebian-signer-extension.ts",
"src/plebian-signer-content-script.ts",
"src/prompt.ts",
- "src/options.ts"
+ "src/options.ts",
+ "src/unlock.ts"
],
"include": ["src/**/*.d.ts"]
}
diff --git a/projects/firefox/custom-webpack.config.ts b/projects/firefox/custom-webpack.config.ts
index 98eabb7..aa81718 100644
--- a/projects/firefox/custom-webpack.config.ts
+++ b/projects/firefox/custom-webpack.config.ts
@@ -22,5 +22,9 @@ module.exports = {
import: 'src/options.ts',
runtime: false,
},
+ unlock: {
+ import: 'src/unlock.ts',
+ runtime: false,
+ },
},
} as Configuration;
diff --git a/projects/firefox/public/manifest.json b/projects/firefox/public/manifest.json
index fa85a5d..ffb46a2 100644
--- a/projects/firefox/public/manifest.json
+++ b/projects/firefox/public/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
- "version": "1.0.9",
+ "version": "1.0.10",
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
"options_page": "options.html",
"permissions": [
diff --git a/projects/firefox/public/unlock.html b/projects/firefox/public/unlock.html
new file mode 100644
index 0000000..8fdb89a
--- /dev/null
+++ b/projects/firefox/public/unlock.html
@@ -0,0 +1,245 @@
+
+
+
+
+ Plebeian Signer - Unlock
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+ is requesting access
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Invalid password
+
+
+
+
+
diff --git a/projects/firefox/src/background-common.ts b/projects/firefox/src/background-common.ts
index 730a6c9..a45f3a4 100644
--- a/projects/firefox/src/background-common.ts
+++ b/projects/firefox/src/background-common.ts
@@ -6,16 +6,38 @@ import {
CryptoHelper,
SignerMetaData,
Identity_DECRYPTED,
+ Identity_ENCRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
+ Relay_DECRYPTED,
+ Relay_ENCRYPTED,
+ NwcConnection_DECRYPTED,
+ NwcConnection_ENCRYPTED,
+ CashuMint_DECRYPTED,
+ CashuMint_ENCRYPTED,
+ deriveKeyArgon2,
} from '@common';
-import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
-import browser from 'webextension-polyfill';
+import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
import { Buffer } from 'buffer';
+import browser from 'webextension-polyfill';
+
+// Unlock request/response message types
+export interface UnlockRequestMessage {
+ type: 'unlock-request';
+ id: string;
+ password: string;
+}
+
+export interface UnlockResponseMessage {
+ type: 'unlock-response';
+ id: string;
+ success: boolean;
+ error?: string;
+}
export const debug = function (message: any) {
const dateString = new Date().toISOString();
@@ -96,13 +118,9 @@ export const getBrowserSyncData = async function (): Promise<
let browserSyncData: BrowserSyncData | undefined;
if (signerMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
- browserSyncData = (await browser.storage.local.get(
- null
- )) as unknown as BrowserSyncData;
+ browserSyncData = (await browser.storage.local.get(null)) as unknown as BrowserSyncData;
} else if (signerMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
- browserSyncData = (await browser.storage.sync.get(
- null
- )) as unknown as BrowserSyncData;
+ browserSyncData = (await browser.storage.sync.get(null)) as unknown as BrowserSyncData;
}
return browserSyncData;
@@ -377,3 +395,352 @@ const encrypt = async function (
// v1: Use password with PBKDF2
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
};
+
+// ==========================================
+// Unlock Vault Logic (for background script)
+// ==========================================
+
+/**
+ * Decrypt a value using AES-GCM with pre-derived key (v2)
+ */
+async function decryptV2(
+ encryptedBase64: string,
+ ivBase64: string,
+ keyBase64: string
+): Promise {
+ const keyBytes = Buffer.from(keyBase64, 'base64');
+ const iv = Buffer.from(ivBase64, 'base64');
+ const cipherText = Buffer.from(encryptedBase64, 'base64');
+
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ keyBytes,
+ { name: 'AES-GCM' },
+ false,
+ ['decrypt']
+ );
+
+ const decrypted = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv },
+ key,
+ cipherText
+ );
+
+ return new TextDecoder().decode(decrypted);
+}
+
+/**
+ * Decrypt a value using PBKDF2 (v1)
+ */
+async function decryptV1(
+ encryptedBase64: string,
+ ivBase64: string,
+ password: string
+): Promise {
+ return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
+}
+
+/**
+ * Generic decrypt function that handles both v1 and v2
+ */
+async function decryptValue(
+ encrypted: string,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ if (isV2) {
+ return decryptV2(encrypted, iv, keyOrPassword);
+ }
+ return decryptV1(encrypted, iv, keyOrPassword);
+}
+
+/**
+ * Parse decrypted value to the desired type
+ */
+function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
+ switch (type) {
+ case 'number':
+ return parseInt(value);
+ case 'boolean':
+ return value === 'true';
+ default:
+ return value;
+ }
+}
+
+/**
+ * Decrypt an identity
+ */
+async function decryptIdentity(
+ identity: Identity_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ return {
+ id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
+ nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
+ createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
+ privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
+ };
+}
+
+/**
+ * Decrypt a permission
+ */
+async function decryptPermission(
+ permission: Permission_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ const decrypted: Permission_DECRYPTED = {
+ id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
+ identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
+ host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
+ method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
+ methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
+ };
+ if (permission.kind) {
+ decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
+ }
+ return decrypted;
+}
+
+/**
+ * Decrypt a relay
+ */
+async function decryptRelay(
+ relay: Relay_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ return {
+ id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
+ identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
+ url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
+ read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
+ write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
+ };
+}
+
+/**
+ * Decrypt an NWC connection
+ */
+async function decryptNwcConnection(
+ nwc: NwcConnection_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ const decrypted: NwcConnection_DECRYPTED = {
+ id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
+ name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
+ connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
+ walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
+ relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
+ secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
+ createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
+ };
+ if (nwc.lud16) {
+ decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
+ }
+ if (nwc.cachedBalance) {
+ decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
+ }
+ if (nwc.cachedBalanceAt) {
+ decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
+ }
+ return decrypted;
+}
+
+/**
+ * Decrypt a Cashu mint
+ */
+async function decryptCashuMint(
+ mint: CashuMint_ENCRYPTED,
+ iv: string,
+ keyOrPassword: string,
+ isV2: boolean
+): Promise {
+ const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
+ const decrypted: CashuMint_DECRYPTED = {
+ id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
+ name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
+ mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
+ unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
+ createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
+ proofs: JSON.parse(proofsJson),
+ };
+ if (mint.cachedBalance) {
+ decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
+ }
+ if (mint.cachedBalanceAt) {
+ decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
+ }
+ return decrypted;
+}
+
+/**
+ * Handle an unlock request from the unlock popup
+ */
+export async function handleUnlockRequest(
+ password: string
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ debug('handleUnlockRequest: Starting unlock...');
+
+ // Check if already unlocked
+ const existingSession = await getBrowserSessionData();
+ if (existingSession) {
+ debug('handleUnlockRequest: Already unlocked');
+ return { success: true };
+ }
+
+ // Get sync data
+ const browserSyncData = await getBrowserSyncData();
+ if (!browserSyncData) {
+ return { success: false, error: 'No vault data found' };
+ }
+
+ // Verify password
+ const passwordHash = await CryptoHelper.hash(password);
+ if (passwordHash !== browserSyncData.vaultHash) {
+ return { success: false, error: 'Invalid password' };
+ }
+ debug('handleUnlockRequest: Password verified');
+
+ // Detect vault version
+ const isV2 = !!browserSyncData.salt;
+ debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
+
+ let keyOrPassword: string;
+ let vaultKey: string | undefined;
+ let vaultPassword: string | undefined;
+
+ if (isV2) {
+ // v2: Derive key with Argon2id (~3 seconds)
+ debug('handleUnlockRequest: Deriving Argon2id key...');
+ const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
+ const keyBytes = await deriveKeyArgon2(password, saltBytes);
+ vaultKey = Buffer.from(keyBytes).toString('base64');
+ keyOrPassword = vaultKey;
+ debug('handleUnlockRequest: Key derived');
+ } else {
+ // v1: Use password directly
+ vaultPassword = password;
+ keyOrPassword = password;
+ }
+
+ // Decrypt identities
+ debug('handleUnlockRequest: Decrypting identities...');
+ const decryptedIdentities: Identity_DECRYPTED[] = [];
+ for (const identity of browserSyncData.identities) {
+ const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedIdentities.push(decrypted);
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
+
+ // Decrypt permissions
+ debug('handleUnlockRequest: Decrypting permissions...');
+ const decryptedPermissions: Permission_DECRYPTED[] = [];
+ for (const permission of browserSyncData.permissions) {
+ try {
+ const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedPermissions.push(decrypted);
+ } catch (e) {
+ debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
+ }
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
+
+ // Decrypt relays
+ debug('handleUnlockRequest: Decrypting relays...');
+ const decryptedRelays: Relay_DECRYPTED[] = [];
+ for (const relay of browserSyncData.relays) {
+ const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedRelays.push(decrypted);
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
+
+ // Decrypt NWC connections
+ debug('handleUnlockRequest: Decrypting NWC connections...');
+ const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
+ for (const nwc of browserSyncData.nwcConnections ?? []) {
+ const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedNwcConnections.push(decrypted);
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
+
+ // Decrypt Cashu mints
+ debug('handleUnlockRequest: Decrypting Cashu mints...');
+ const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
+ for (const mint of browserSyncData.cashuMints ?? []) {
+ const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
+ decryptedCashuMints.push(decrypted);
+ }
+ debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
+
+ // Decrypt selectedIdentityId
+ let decryptedSelectedIdentityId: string | null = null;
+ if (browserSyncData.selectedIdentityId !== null) {
+ decryptedSelectedIdentityId = await decryptValue(
+ browserSyncData.selectedIdentityId,
+ browserSyncData.iv,
+ keyOrPassword,
+ isV2
+ );
+ }
+ debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
+
+ // Build session data
+ const browserSessionData: BrowserSessionData = {
+ vaultPassword: isV2 ? undefined : vaultPassword,
+ vaultKey: isV2 ? vaultKey : undefined,
+ iv: browserSyncData.iv,
+ salt: browserSyncData.salt,
+ permissions: decryptedPermissions,
+ identities: decryptedIdentities,
+ selectedIdentityId: decryptedSelectedIdentityId,
+ relays: decryptedRelays,
+ nwcConnections: decryptedNwcConnections,
+ cashuMints: decryptedCashuMints,
+ };
+
+ // Save session data
+ debug('handleUnlockRequest: Saving session data...');
+ await browser.storage.session.set(browserSessionData as unknown as Record);
+ debug('handleUnlockRequest: Unlock complete!');
+
+ return { success: true };
+ } catch (error: any) {
+ debug(`handleUnlockRequest: Error: ${error.message}`);
+ return { success: false, error: error.message || 'Unlock failed' };
+ }
+}
+
+/**
+ * Open the unlock popup window
+ */
+export async function openUnlockPopup(host?: string): Promise {
+ const width = 375;
+ const height = 500;
+ const { top, left } = await getPosition(width, height);
+
+ const id = crypto.randomUUID();
+ let url = `unlock.html?id=${id}`;
+ if (host) {
+ url += `&host=${encodeURIComponent(host)}`;
+ }
+
+ await browser.windows.create({
+ type: 'popup',
+ url,
+ height,
+ width,
+ top,
+ left,
+ });
+}
diff --git a/projects/firefox/src/background.ts b/projects/firefox/src/background.ts
index 4433a1b..1c658c8 100644
--- a/projects/firefox/src/background.ts
+++ b/projects/firefox/src/background.ts
@@ -10,15 +10,19 @@ import {
debug,
getBrowserSessionData,
getPosition,
+ handleUnlockRequest,
nip04Decrypt,
nip04Encrypt,
nip44Decrypt,
nip44Encrypt,
+ openUnlockPopup,
PromptResponse,
PromptResponseMessage,
shouldRecklessModeApprove,
signEvent,
storePermission,
+ UnlockRequestMessage,
+ UnlockResponseMessage,
} from './background-common';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
@@ -33,8 +37,49 @@ const openPrompts = new Map<
}
>();
+// Track if unlock popup is already open
+let unlockPopupOpen = false;
+
+// Queue of pending NIP-07 requests waiting for unlock
+const pendingRequests: {
+ request: BackgroundRequestMessage;
+ resolve: (result: any) => void;
+ reject: (error: any) => void;
+}[] = [];
+
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
debug('Message received');
+
+ // Handle unlock request from unlock popup
+ if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
+ const unlockReq = message as UnlockRequestMessage;
+ debug('Processing unlock request');
+ const result = await handleUnlockRequest(unlockReq.password);
+ const response: UnlockResponseMessage = {
+ type: 'unlock-response',
+ id: unlockReq.id,
+ success: result.success,
+ error: result.error,
+ };
+
+ if (result.success) {
+ unlockPopupOpen = false;
+ // Process any pending NIP-07 requests
+ debug(`Processing ${pendingRequests.length} pending requests`);
+ while (pendingRequests.length > 0) {
+ const pending = pendingRequests.shift()!;
+ try {
+ const pendingResult = await processNip07Request(pending.request);
+ pending.resolve(pendingResult);
+ } catch (error) {
+ pending.reject(error);
+ }
+ }
+ }
+
+ return response;
+ }
+
const request = message as BackgroundRequestMessage | PromptResponseMessage;
debug(request);
@@ -55,6 +100,32 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
const browserSessionData = await getBrowserSessionData();
+ if (!browserSessionData) {
+ // Vault is locked - open unlock popup and queue the request
+ const req = request as BackgroundRequestMessage;
+ debug('Vault locked, opening unlock popup');
+
+ if (!unlockPopupOpen) {
+ unlockPopupOpen = true;
+ await openUnlockPopup(req.host);
+ }
+
+ // Queue this request to be processed after unlock
+ return new Promise((resolve, reject) => {
+ pendingRequests.push({ request: req, resolve, reject });
+ });
+ }
+
+ // Process the NIP-07 request
+ return processNip07Request(request as BackgroundRequestMessage);
+});
+
+/**
+ * Process a NIP-07 request after vault is unlocked
+ */
+async function processNip07Request(req: BackgroundRequestMessage): Promise {
+ const browserSessionData = await getBrowserSessionData();
+
if (!browserSessionData) {
throw new Error('Plebeian Signer vault not unlocked by the user.');
}
@@ -67,8 +138,6 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
throw new Error('No Nostr identity available at endpoint.');
}
- const req = request as BackgroundRequestMessage;
-
// Check reckless mode first
const recklessApprove = await shouldRecklessModeApprove(req.host);
debug(`recklessApprove result: ${recklessApprove}`);
@@ -212,4 +281,4 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
default:
throw new Error(`Not supported request method '${req.method}'.`);
}
-});
+}
diff --git a/projects/firefox/src/unlock.ts b/projects/firefox/src/unlock.ts
new file mode 100644
index 0000000..81ba8cf
--- /dev/null
+++ b/projects/firefox/src/unlock.ts
@@ -0,0 +1,106 @@
+import browser from 'webextension-polyfill';
+
+export interface UnlockRequestMessage {
+ type: 'unlock-request';
+ id: string;
+ password: string;
+}
+
+export interface UnlockResponseMessage {
+ type: 'unlock-response';
+ id: string;
+ success: boolean;
+ error?: string;
+}
+
+const params = new URLSearchParams(location.search);
+const id = params.get('id') as string;
+const host = params.get('host');
+
+// Elements
+const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
+const togglePasswordBtn = document.getElementById('togglePassword');
+const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
+const derivingOverlay = document.getElementById('derivingOverlay');
+const errorAlert = document.getElementById('errorAlert');
+const errorMessage = document.getElementById('errorMessage');
+const hostInfo = document.getElementById('hostInfo');
+const hostSpan = document.getElementById('hostSpan');
+
+// Show host info if available
+if (host && hostInfo && hostSpan) {
+ hostSpan.innerText = host;
+ hostInfo.classList.remove('hidden');
+}
+
+// Toggle password visibility
+togglePasswordBtn?.addEventListener('click', () => {
+ if (passwordInput.type === 'password') {
+ passwordInput.type = 'text';
+ togglePasswordBtn.innerHTML = '';
+ } else {
+ passwordInput.type = 'password';
+ togglePasswordBtn.innerHTML = '';
+ }
+});
+
+// Enable/disable unlock button based on password input
+passwordInput?.addEventListener('input', () => {
+ unlockBtn.disabled = !passwordInput.value;
+});
+
+// Handle enter key
+passwordInput?.addEventListener('keyup', (e) => {
+ if (e.key === 'Enter' && passwordInput.value) {
+ attemptUnlock();
+ }
+});
+
+// Handle unlock button click
+unlockBtn?.addEventListener('click', attemptUnlock);
+
+async function attemptUnlock() {
+ if (!passwordInput?.value) return;
+
+ // Show deriving overlay
+ derivingOverlay?.classList.remove('hidden');
+ errorAlert?.classList.add('hidden');
+
+ const message: UnlockRequestMessage = {
+ type: 'unlock-request',
+ id,
+ password: passwordInput.value,
+ };
+
+ try {
+ const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
+
+ if (response.success) {
+ // Success - close the window
+ window.close();
+ } else {
+ // Failed - show error
+ derivingOverlay?.classList.add('hidden');
+ showError(response.error || 'Invalid password');
+ }
+ } catch (error) {
+ console.error('Failed to send unlock message:', error);
+ derivingOverlay?.classList.add('hidden');
+ showError('Failed to unlock vault');
+ }
+}
+
+function showError(message: string) {
+ if (errorAlert && errorMessage) {
+ errorMessage.innerText = message;
+ errorAlert.classList.remove('hidden');
+ setTimeout(() => {
+ errorAlert.classList.add('hidden');
+ }, 3000);
+ }
+}
+
+// Focus password input on load
+document.addEventListener('DOMContentLoaded', () => {
+ passwordInput?.focus();
+});
diff --git a/projects/firefox/tsconfig.app.json b/projects/firefox/tsconfig.app.json
index c6b574a..a2e03c4 100644
--- a/projects/firefox/tsconfig.app.json
+++ b/projects/firefox/tsconfig.app.json
@@ -12,7 +12,8 @@
"src/plebian-signer-extension.ts",
"src/plebian-signer-content-script.ts",
"src/prompt.ts",
- "src/options.ts"
+ "src/options.ts",
+ "src/unlock.ts"
],
"include": ["src/**/*.d.ts"]
}
diff --git a/releases/plebeian-signer-chrome-v1.0.9.zip b/releases/plebeian-signer-chrome-v1.0.10.zip
similarity index 86%
rename from releases/plebeian-signer-chrome-v1.0.9.zip
rename to releases/plebeian-signer-chrome-v1.0.10.zip
index e1d466e..d0dcd77 100644
Binary files a/releases/plebeian-signer-chrome-v1.0.9.zip and b/releases/plebeian-signer-chrome-v1.0.10.zip differ
diff --git a/releases/plebeian-signer-firefox-v1.0.9.zip b/releases/plebeian-signer-firefox-v1.0.10.zip
similarity index 85%
rename from releases/plebeian-signer-firefox-v1.0.9.zip
rename to releases/plebeian-signer-firefox-v1.0.10.zip
index 5a2d8f6..7bb9f73 100644
Binary files a/releases/plebeian-signer-firefox-v1.0.9.zip and b/releases/plebeian-signer-firefox-v1.0.10.zip differ