|
| 1 | +// This module is browser-only (IndexedDB + Web Crypto). |
| 2 | +// crypto.subtle is a global in all modern browsers and Node 18+. |
| 3 | +// |
| 4 | +// Overview of the passphrase encryption flow: |
| 5 | +// 1. A non-extractable AES-GCM-256 key is generated once and persisted in |
| 6 | +// IndexedDB so it survives page reloads without ever being exposed to JS. |
| 7 | +// 2. When a passphrase needs to be stored, it is encrypted with that key |
| 8 | +// using a fresh random IV (encryptPassphrase). The result — IV + ciphertext |
| 9 | +// — is safe to keep in less-secure storage (e.g. localStorage, sync storage) |
| 10 | +// because without the IndexedDB key it is opaque. |
| 11 | +// 3. When the passphrase is needed again, the stored payload is decrypted with |
| 12 | +// the same key (decryptPassphrase) and the plaintext is returned in memory. |
| 13 | + |
| 14 | +// IndexedDB coordinates — the database, object-store, and key name under which |
| 15 | +// the CryptoKey is persisted. |
| 16 | +const DB_NAME = 'tb-secure-store'; |
| 17 | +const DB_VERSION = 1; |
| 18 | +const STORE_NAME = 'keys'; |
| 19 | +const KEY_ID = 'passphrase-key'; |
| 20 | + |
| 21 | +// Serialisable representation of an encrypted passphrase. |
| 22 | +// Both iv and data are stored as plain number arrays so they can be JSON-serialised |
| 23 | +// and saved anywhere (e.g. browser extension storage). |
| 24 | +export interface EncryptedPassphrase { |
| 25 | + iv: number[]; // 12-byte AES-GCM initialisation vector |
| 26 | + data: number[]; // AES-GCM ciphertext + authentication tag |
| 27 | +} |
| 28 | + |
| 29 | +// Guard for environments that do not expose IndexedDB (e.g. some service workers |
| 30 | +// or non-browser test runners). Returns false when the API is absent. |
| 31 | +function isIndexedDbAvailable(): boolean { |
| 32 | + return typeof indexedDB !== 'undefined'; |
| 33 | +} |
| 34 | + |
| 35 | +// Opens (and, on first run, creates) the IndexedDB database. |
| 36 | +// The onupgradeneeded callback runs only when the database is new or the version |
| 37 | +// number bumps, and it is responsible for creating the object store. |
| 38 | +function openDb(): Promise<IDBDatabase> { |
| 39 | + return new Promise((resolve, reject) => { |
| 40 | + const request = indexedDB.open(DB_NAME, DB_VERSION); |
| 41 | + request.onupgradeneeded = () => { |
| 42 | + // Create the object store the first time the DB is initialised. |
| 43 | + request.result.createObjectStore(STORE_NAME); |
| 44 | + }; |
| 45 | + request.onsuccess = () => resolve(request.result); |
| 46 | + request.onerror = () => reject(request.error); |
| 47 | + }); |
| 48 | +} |
| 49 | + |
| 50 | +// Attempts to retrieve the stored CryptoKey from IndexedDB. |
| 51 | +// Returns null when no key has been persisted yet (first-time use). |
| 52 | +function getKeyFromDb(db: IDBDatabase): Promise<CryptoKey | null> { |
| 53 | + return new Promise((resolve, reject) => { |
| 54 | + const tx = db.transaction(STORE_NAME, 'readonly'); |
| 55 | + const req = tx.objectStore(STORE_NAME).get(KEY_ID); |
| 56 | + req.onsuccess = () => resolve((req.result as CryptoKey) ?? null); |
| 57 | + req.onerror = () => reject(req.error); |
| 58 | + }); |
| 59 | +} |
| 60 | + |
| 61 | +// Persists a CryptoKey into IndexedDB under the well-known KEY_ID. |
| 62 | +// The key is stored as a native CryptoKey object — the browser serialises it |
| 63 | +// internally and it cannot be read back as raw bytes from JS. |
| 64 | +function storeKeyInDb(db: IDBDatabase, key: CryptoKey): Promise<void> { |
| 65 | + return new Promise((resolve, reject) => { |
| 66 | + const tx = db.transaction(STORE_NAME, 'readwrite'); |
| 67 | + const req = tx.objectStore(STORE_NAME).put(key, KEY_ID); |
| 68 | + req.onsuccess = () => resolve(); |
| 69 | + req.onerror = () => reject(req.error); |
| 70 | + }); |
| 71 | +} |
| 72 | + |
| 73 | +// Returns the AES-GCM encryption key, creating and persisting it on first call. |
| 74 | +// |
| 75 | +// The key is marked non-extractable (extractable: false) so the Web Crypto API |
| 76 | +// will refuse any attempt to export the raw key bytes — limiting the attack |
| 77 | +// surface to the IndexedDB store itself. |
| 78 | +// |
| 79 | +// Returns null when IndexedDB is not available, signalling to callers that |
| 80 | +// encrypted storage is not supported in the current environment. |
| 81 | +export async function getOrCreatePassphraseKey(): Promise<CryptoKey | null> { |
| 82 | + if (!isIndexedDbAvailable()) return null; |
| 83 | + |
| 84 | + const db = await openDb(); |
| 85 | + let key = await getKeyFromDb(db); |
| 86 | + |
| 87 | + if (!key) { |
| 88 | + // No key found — generate a fresh 256-bit AES-GCM key and store it. |
| 89 | + // extractable: false prevents the raw key bytes from ever leaving the browser. |
| 90 | + key = await crypto.subtle.generateKey( |
| 91 | + { name: 'AES-GCM', length: 256 }, |
| 92 | + false, |
| 93 | + ['encrypt', 'decrypt'] |
| 94 | + ); |
| 95 | + await storeKeyInDb(db, key); |
| 96 | + } |
| 97 | + |
| 98 | + db.close(); |
| 99 | + return key; |
| 100 | +} |
| 101 | + |
| 102 | +// Encrypts a plaintext passphrase with the given AES-GCM key. |
| 103 | +// |
| 104 | +// A new 12-byte IV is generated for every encryption call — reusing an IV with |
| 105 | +// the same key would break AES-GCM's security guarantees, so this must never |
| 106 | +// be skipped. |
| 107 | +// |
| 108 | +// The returned object contains both the IV and the ciphertext (which includes |
| 109 | +// the AES-GCM authentication tag appended by the browser) as plain number arrays |
| 110 | +// so the caller can serialise them freely. |
| 111 | +export async function encryptPassphrase( |
| 112 | + key: CryptoKey, |
| 113 | + plaintext: string |
| 114 | +): Promise<EncryptedPassphrase> { |
| 115 | + // Generate a fresh, cryptographically random 12-byte IV for this operation. |
| 116 | + const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 117 | + |
| 118 | + // Encode the passphrase string to UTF-8 bytes before handing it to the cipher. |
| 119 | + const encoded = new TextEncoder().encode(plaintext); |
| 120 | + |
| 121 | + // Encrypt; the browser appends a 16-byte authentication tag to the ciphertext. |
| 122 | + const ciphertext = await crypto.subtle.encrypt( |
| 123 | + { name: 'AES-GCM', iv }, |
| 124 | + key, |
| 125 | + encoded |
| 126 | + ); |
| 127 | + |
| 128 | + // Convert typed arrays to plain arrays for JSON-safe serialisation. |
| 129 | + return { |
| 130 | + iv: Array.from(iv), |
| 131 | + data: Array.from(new Uint8Array(ciphertext)), |
| 132 | + }; |
| 133 | +} |
| 134 | + |
| 135 | +// Decrypts an EncryptedPassphrase payload and returns the original plaintext. |
| 136 | +// |
| 137 | +// AES-GCM authenticates the ciphertext during decryption — if the data or the IV |
| 138 | +// have been tampered with, crypto.subtle.decrypt will throw and the caller will |
| 139 | +// receive a rejection rather than corrupted plaintext. |
| 140 | +export async function decryptPassphrase( |
| 141 | + key: CryptoKey, |
| 142 | + payload: EncryptedPassphrase |
| 143 | +): Promise<string> { |
| 144 | + // Restore typed arrays from the stored plain-number representations. |
| 145 | + const iv = new Uint8Array(payload.iv); |
| 146 | + const data = new Uint8Array(payload.data); |
| 147 | + |
| 148 | + // Decrypt and verify the authentication tag in one step. |
| 149 | + const decrypted = await crypto.subtle.decrypt( |
| 150 | + { name: 'AES-GCM', iv }, |
| 151 | + key, |
| 152 | + data |
| 153 | + ); |
| 154 | + |
| 155 | + // Decode the raw bytes back to a UTF-8 string and return it. |
| 156 | + return new TextDecoder().decode(decrypted); |
| 157 | +} |
0 commit comments