From e38a5dd399ebfe1b7d9f184a84e7f785e1b17110 Mon Sep 17 00:00:00 2001 From: smoothiepool Date: Thu, 11 Dec 2025 12:36:15 -0600 Subject: [PATCH 01/22] Implement session management for unlock state with caching and restoration functionality. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’re using chrome.storage.session because it’s ephemeral to the current browser session: it survives service-worker restarts (solving the restart problem) but is cleared when the browser restarts or the session ends. chrome.storage.local is persisted to disk and would keep the derived key across browser restarts and system reboots, which is a larger security exposure and would undermine the intent of auto-lock. this should resolve https://github.com/nockbox/iris/issues/13 --- extension/background/index.ts | 118 +++++++++++++++++++++++++++++++++- extension/shared/constants.ts | 8 +++ extension/shared/vault.ts | 60 +++++++++++++++++ extension/shared/webcrypto.ts | 2 +- package-lock.json | 5 -- 5 files changed, 185 insertions(+), 8 deletions(-) diff --git a/extension/background/index.ts b/extension/background/index.ts index 831d37d..f03d742 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -13,6 +13,7 @@ import { ALARM_NAMES, AUTOLOCK_MINUTES, STORAGE_KEYS, + SESSION_STORAGE_KEYS, USER_ACTIVITY_METHODS, UI_CONSTANTS, APPROVAL_CONSTANTS, @@ -55,6 +56,101 @@ const REQUEST_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes */ let isRpcConnected = true; +type UnlockSessionCache = { + key: number[]; +}; + +let sessionRestorePromise: Promise | null = null; + +async function clearUnlockSessionCache(): Promise { + try { + await chrome.storage.session?.remove(SESSION_STORAGE_KEYS.UNLOCK_CACHE); + } catch (error) { + console.error('[Background] Failed to clear unlock cache:', error); + } +} + +async function persistUnlockSession(): Promise { + const sessionStorage = chrome.storage.session; + if (!sessionStorage || vault.isLocked()) { + return; + } + + const encryptionKey = vault.getEncryptionKey(); + if (!encryptionKey) { + return; + } + + try { + const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', encryptionKey)); + await sessionStorage.set({ + [SESSION_STORAGE_KEYS.UNLOCK_CACHE]: Array.from(rawKey), + }); + } catch (error) { + console.error('[Background] Failed to persist unlock session:', error); + } +} + +async function restoreUnlockSession(): Promise { + const sessionStorage = chrome.storage.session; + if (!sessionStorage) { + return; + } + + const stored = await sessionStorage.get([SESSION_STORAGE_KEYS.UNLOCK_CACHE]); + const cached = stored[SESSION_STORAGE_KEYS.UNLOCK_CACHE] as UnlockSessionCache['key'] | undefined; + + if (!cached || cached.length === 0) { + return; + } + + // Respect manual lock - never auto-unlock if user explicitly locked + if (manuallyLocked) { + await clearUnlockSessionCache(); + return; + } + + // Respect auto-lock timeout window + if (autoLockMinutes > 0) { + const idleMs = Date.now() - lastActivity; + if (idleMs >= autoLockMinutes * 60_000) { + await clearUnlockSessionCache(); + return; + } + } + + try { + const key = await crypto.subtle.importKey( + 'raw', + new Uint8Array(cached), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + const result = await vault.unlockWithKey(key); + if ('error' in result) { + await clearUnlockSessionCache(); + } + } catch (error) { + console.error('[Background] Failed to restore unlock session:', error); + await clearUnlockSessionCache(); + } +} + +async function ensureSessionRestored(): Promise { + if (!vault.isLocked()) { + return; + } + + if (!sessionRestorePromise) { + sessionRestorePromise = restoreUnlockSession().finally(() => { + sessionRestorePromise = null; + }); + } + + await sessionRestorePromise; +} + /** * Load approved origins from storage */ @@ -325,7 +421,7 @@ async function emitWalletEvent(eventType: string, data: unknown) { } // Initialize auto-lock setting, load approved origins, vault state, connection monitoring, and schedule alarms -(async () => { +const initPromise = (async () => { const stored = await chrome.storage.local.get([ STORAGE_KEYS.AUTO_LOCK_MINUTES, STORAGE_KEYS.LAST_ACTIVITY, @@ -343,6 +439,8 @@ async function emitWalletEvent(eventType: string, data: unknown) { await loadApprovedOrigins(); await vault.init(); // Load encrypted vault header to detect vault existence + await restoreUnlockSession(); // Rehydrate unlock state if still within auto-lock window + // Only schedule alarm if auto-lock is enabled, otherwise ensure any stale alarm is cleared if (autoLockMinutes > 0) { scheduleAlarm(); @@ -389,6 +487,8 @@ function isFromPopup(sender: chrome.runtime.MessageSender): boolean { */ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { (async () => { + await initPromise; + await ensureSessionRestored(); const { payload } = msg || {}; await touchActivity(payload?.method); @@ -621,6 +721,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Clear manual lock flag when successfully unlocked manuallyLocked = false; await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); + await persistUnlockSession(); await emitWalletEvent('connect', { chainId: 'nockchain-1' }); } return; @@ -630,6 +731,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { manuallyLocked = true; await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: true }); await vault.lock(); + await clearUnlockSessionCache(); sendResponse({ ok: true }); // Emit disconnect event when wallet locks @@ -639,6 +741,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case INTERNAL_METHODS.RESET_WALLET: // Reset the wallet completely - clears all data await vault.reset(); + await clearUnlockSessionCache(); manuallyLocked = false; sendResponse({ ok: true }); @@ -648,7 +751,14 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case INTERNAL_METHODS.SETUP: // params: password, mnemonic (optional). If no mnemonic, generates one automatically. - sendResponse(await vault.setup(payload.params?.[0], payload.params?.[1])); + const setupResult = await vault.setup(payload.params?.[0], payload.params?.[1]); + sendResponse(setupResult); + + if ('ok' in setupResult && setupResult.ok) { + manuallyLocked = false; + await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); + await persistUnlockSession(); + } return; case INTERNAL_METHODS.GET_STATE: @@ -1233,6 +1343,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { chrome.alarms.onAlarm.addListener(async alarm => { if (alarm.name !== ALARM_NAMES.AUTO_LOCK) return; + await initPromise; + await ensureSessionRestored(); + // Don't auto-lock if set to "never" (0 minutes) - stop the alarm cycle if (autoLockMinutes <= 0) { chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); @@ -1248,6 +1361,7 @@ chrome.alarms.onAlarm.addListener(async alarm => { if (idleMs >= autoLockMinutes * 60_000) { try { await vault.lock(); + await clearUnlockSessionCache(); // Notify popup to update UI immediately await emitWalletEvent('LOCKED', { reason: 'auto-lock' }); } catch (error) { diff --git a/extension/shared/constants.ts b/extension/shared/constants.ts index 627d858..a665708 100644 --- a/extension/shared/constants.ts +++ b/extension/shared/constants.ts @@ -220,6 +220,14 @@ export const STORAGE_KEYS = { MANUALLY_LOCKED: 'manuallyLocked', } as const; +/** + * Chrome Session Storage Keys - ephemeral cache for unlocked session data + */ +export const SESSION_STORAGE_KEYS = { + /** Cached encryption key to restore unlock state after SW restarts */ + UNLOCK_CACHE: 'unlockCache', +} as const; + /** Current storage schema version - increment when making breaking changes */ export const CURRENT_SCHEMA_VERSION = 1; diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index abeaa20..7c8ea54 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -372,6 +372,66 @@ export class Vault { } } + /** + * Unlocks the vault using a cached encryption key (used for session restore) + */ + async unlockWithKey( + key: CryptoKey + ): Promise< + | { ok: boolean; address: string; accounts: Account[]; currentAccount: Account } + | { error: string } + > { + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.ENCRYPTED_VAULT, + STORAGE_KEYS.CURRENT_ACCOUNT_INDEX, + ]); + const enc = stored[STORAGE_KEYS.ENCRYPTED_VAULT] as EncryptedVault | undefined; + const currentAccountIndex = + (stored[STORAGE_KEYS.CURRENT_ACCOUNT_INDEX] as number | undefined) || 0; + + if (!enc) { + return { error: ERROR_CODES.NO_VAULT }; + } + + const pt = await decryptGCM( + key, + new Uint8Array(enc.cipher.iv), + new Uint8Array(enc.cipher.ct) + ).catch(() => null); + + if (!pt) { + return { error: ERROR_CODES.BAD_PASSWORD }; + } + + const payload = JSON.parse(pt) as VaultPayload; + const accounts = payload.accounts; + + this.mnemonic = payload.mnemonic; + this.encryptionKey = key; + + this.state = { + locked: false, + accounts, + currentAccountIndex, + enc, + }; + + const currentAccount = accounts[currentAccountIndex] || accounts[0]; + return { + ok: true, + address: currentAccount?.address || '', + accounts, + currentAccount, + }; + } + + /** + * Returns the cached encryption key (null when locked) + */ + getEncryptionKey(): CryptoKey | null { + return this.encryptionKey; + } + /** * Helper method to save accounts back to the encrypted vault * Called whenever accounts are modified (create, rename, update styling, hide) diff --git a/extension/shared/webcrypto.ts b/extension/shared/webcrypto.ts index b6b56f6..8c4ede5 100644 --- a/extension/shared/webcrypto.ts +++ b/extension/shared/webcrypto.ts @@ -35,7 +35,7 @@ export async function deriveKeyPBKDF2( }, baseKey, { name: 'AES-GCM', length: 256 }, - false, + true, ['encrypt', 'decrypt'] ); // Return key and salt as plain object (safe - no mutation of CryptoKey) diff --git a/package-lock.json b/package-lock.json index 8a9125f..6298fdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1292,7 +1292,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1428,7 +1427,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -2445,7 +2443,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2504,7 +2501,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2680,7 +2676,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", From 5263343b682cb4da0acf5dc114c5c485c368da57 Mon Sep 17 00:00:00 2001 From: smoothiepool Date: Thu, 11 Dec 2025 13:33:56 -0600 Subject: [PATCH 02/22] Update version to 0.1.1 in package.json, package-lock.json, and manifest.json --- extension/manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extension/manifest.json b/extension/manifest.json index e61e198..bca8934 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Iris Wallet", "homepage_url": "https://iriswallet.io", - "version": "0.1.0", + "version": "0.1.1", "description": "Iris Wallet - Browser Wallet for Nockchain", "icons": { "16": "icons/icon16.png", diff --git a/package-lock.json b/package-lock.json index 6298fdf..7dadbae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "iris", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iris", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/lora": "^5.2.8", diff --git a/package.json b/package.json index 22644a9..9b185ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iris", - "version": "0.1.0", + "version": "0.1.1", "description": "Iris - Chrome Wallet Extension for Nockchain", "type": "module", "scripts": { From d21ded8900ead037d3b96c5426cc36eb085a4287 Mon Sep 17 00:00:00 2001 From: smoothiepool Date: Fri, 19 Dec 2025 15:31:23 -0600 Subject: [PATCH 03/22] Add memo display to SignRawTxScreen for enhanced transaction details --- .nvmrc | 2 + .../screens/approvals/SignRawTxScreen.tsx | 18 +- extension/popup/utils/memo.ts | 204 ++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 .nvmrc create mode 100644 extension/popup/utils/memo.ts diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..35f4978 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +20 + diff --git a/extension/popup/screens/approvals/SignRawTxScreen.tsx b/extension/popup/screens/approvals/SignRawTxScreen.tsx index db0eb0a..58521f2 100644 --- a/extension/popup/screens/approvals/SignRawTxScreen.tsx +++ b/extension/popup/screens/approvals/SignRawTxScreen.tsx @@ -8,6 +8,7 @@ import { AccountIcon } from '../../components/AccountIcon'; import { SiteIcon } from '../../components/SiteIcon'; import { truncateAddress } from '../../utils/format'; import { nickToNock, formatNock } from '../../../shared/currency'; +import { extractMemo } from '../../utils/memo'; interface NoteItemProps { note: any; @@ -90,7 +91,7 @@ export function SignRawTxScreen() { return null; } - const { id, origin, rawTx, notes, spendConditions, outputs } = pendingSignRawTxRequest; + const { id, origin, rawTx, notes, outputs } = pendingSignRawTxRequest; useAutoRejectOnClose(id, INTERNAL_METHODS.REJECT_SIGN_RAW_TX); @@ -123,6 +124,7 @@ export function SignRawTxScreen() { const totalFeeNocks = nickToNock(totalFeeNicks); const formattedFee = formatNock(totalFeeNocks); + const memo = extractMemo({ rawTx, outputs }); const bg = 'var(--color-bg)'; const surface = 'var(--color-surface-800)'; @@ -211,6 +213,20 @@ export function SignRawTxScreen() { )} + {/* Memo (optional) */} + {memo && ( +
+ +
+

+ {memo} +

+
+
+ )} + {/* Network Fee */}