From bd9407b56b9fdd49f79a78756e6dffc8ddcdb765 Mon Sep 17 00:00:00 2001 From: waterWang Date: Mon, 3 Aug 2026 11:49:16 +0800 Subject: [PATCH] feat: encrypt localStorage values with WebCrypto AES-GCM (non-extractable key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the plaintext webStore with a webCryptoStore that encrypts all values before writing to localStorage using AES-GCM with a non-extractable CryptoKey stored in IndexedDB. Key design: - AES-GCM 256-bit key, generated with extractable: false — key material never leaves the WebCrypto subsystem - CryptoKey is persisted in IndexedDB (structured-cloneable) - Each write uses a random 12-byte IV prepended to the ciphertext - Legacy plaintext data (stellar_keypair, micopay_users) is detected and re-encrypted on first load after upgrade - Native (Capacitor) path unchanged — uses @aparajita/capacitor-secure-storage Closes #347 --- .../frontend/src/services/secureStorage.ts | 159 +++++++++++++++++- 1 file changed, 153 insertions(+), 6 deletions(-) diff --git a/micopay/frontend/src/services/secureStorage.ts b/micopay/frontend/src/services/secureStorage.ts index a0a397e..d4062ad 100644 --- a/micopay/frontend/src/services/secureStorage.ts +++ b/micopay/frontend/src/services/secureStorage.ts @@ -10,22 +10,167 @@ interface KvStore { remove(key: string): Promise; } -const webStore: KvStore = { +// ── WebCrypto encrypted storage (web/PWA) ── + +const DB_NAME = 'micopay-crypto-store'; +const DB_VERSION = 1; +const KEY_STORE = 'crypto-keys'; +const KEY_ID = 'aes-gcm-key'; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + req.result.createObjectStore(KEY_STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** + * Get or create the non-extractable AES-GCM key stored in IndexedDB. + * The key is created with `extractable: false`, so the key material can + * never be exported — it can only be used via `crypto.subtle.encrypt()` / + * `crypto.subtle.decrypt()`. This prevents XSS from reading the key. + */ +async function getCryptoKey(): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(KEY_STORE, 'readonly'); + const store = tx.objectStore(KEY_STORE); + const req = store.get(KEY_ID); + req.onsuccess = async () => { + if (req.result) { + resolve(req.result as CryptoKey); + } else { + // Generate a new AES-GCM key — non-extractable + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + false, // non-extractable + ['encrypt', 'decrypt'], + ); + // Persist in IndexedDB (CryptoKey is structured-cloneable) + const writeTx = db.transaction(KEY_STORE, 'readwrite'); + await new Promise((res, rej) => { + const putReq = writeTx.objectStore(KEY_STORE).put(key, KEY_ID); + putReq.onsuccess = () => res(); + putReq.onerror = () => rej(putReq.error); + }); + resolve(key); + } + }; + req.onerror = () => reject(req.error); + }); +} + +/** Uint8Array → base64 */ +function bufToBase64(buf: Uint8Array): string { + let binary = ''; + for (let i = 0; i < buf.byteLength; i++) binary += String.fromCharCode(buf[i]); + return btoa(binary); +} + +/** base64 → Uint8Array */ +function base64ToBuf(b64: string): Uint8Array { + const binary = atob(b64); + const buf = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i); + return buf; +} + +/** + * Encrypt a string value using AES-GCM. + * Returns base64(IV || ciphertext) — the IV is random and prepended. + */ +async function encrypt(value: string): Promise { + const key = await getCryptoKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(value); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoded, + ); + // Prepend the 12-byte IV to the ciphertext (which includes the 16-byte GCM tag) + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), iv.length); + return bufToBase64(combined); +} + +/** + * Decrypt a base64(IV || ciphertext) string back to plaintext. + */ +async function decrypt(encoded: string): Promise { + const key = await getCryptoKey(); + const combined = base64ToBuf(encoded); + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext, + ); + return new TextDecoder().decode(decrypted); +} + +// ── Migration: detect and re-encrypt legacy plaintext data ── + +const LEGACY_MIGRATED_KEY = '__micopay_crypto_migrated'; + +/** Keys that may contain sensitive data and should be re-encrypted. */ +const SENSITIVE_KEYS = ['stellar_keypair', 'micopay_users']; + +async function maybeMigrateLegacyData(): Promise { + if (localStorage.getItem(LEGACY_MIGRATED_KEY)) return; + + for (const key of SENSITIVE_KEYS) { + const raw = localStorage.getItem(key); + if (!raw) continue; + // If the value is valid JSON, it's likely plaintext that needs migration + try { + JSON.parse(raw); + // Re-encrypt it + const encrypted = await encrypt(raw); + localStorage.setItem(key, encrypted); + } catch { + // Already encrypted or not valid JSON — skip + } + } + localStorage.setItem(LEGACY_MIGRATED_KEY, 'true'); +} + +const webCryptoStore: KvStore = { async get(key) { - return window.localStorage.getItem(key); + const raw = localStorage.getItem(key); + if (!raw) return null; + // Try to decrypt — if it fails, fall back to plaintext (legacy data) + try { + return await decrypt(raw); + } catch { + // Legacy plaintext or non-encrypted data + return raw; + } }, async set(key, value) { - window.localStorage.setItem(key, value); + const encrypted = await encrypt(value); + localStorage.setItem(key, encrypted); }, async remove(key) { - window.localStorage.removeItem(key); + localStorage.removeItem(key); }, }; +// ── Native (Capacitor Secure Storage) ── + let nativeStorePromise: Promise | null = null; async function getStore(): Promise { - if (!Capacitor.isNativePlatform()) return webStore; + if (!Capacitor.isNativePlatform()) { + await maybeMigrateLegacyData(); + return webCryptoStore; + } if (!nativeStorePromise) { nativeStorePromise = import('@aparajita/capacitor-secure-storage').then(({ SecureStorage }) => ({ async get(key) { @@ -43,6 +188,8 @@ async function getStore(): Promise { return nativeStorePromise; } +// ── Public API ── + export async function readJSON(key: string): Promise { const store = await getStore(); const raw = await store.get(key); @@ -73,4 +220,4 @@ export async function setBackupConfirmed(): Promise { export async function isBackupConfirmed(): Promise { const confirmed = await readJSON(BACKUP_CONFIRMED_KEY); return !!confirmed; -} +} \ No newline at end of file