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 + + + + + +
+
+ Plebeian Signer +
+ +
+
+ +
+ + + +
+ + +
+ + +
+
+ + + + + + + + + + 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 + + + + + +
+
+ Plebeian Signer +
+ +
+
+ +
+ + + +
+ + +
+ + +
+
+ + + + + + + + + + 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