Skip to content

Commit 0d1f6ef

Browse files
committed
encrypt passphrase full
1 parent af39067 commit 0d1f6ef

5 files changed

Lines changed: 221 additions & 14 deletions

File tree

packages/send/frontend/src/apps/send/views/PopupView.vue

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { useUploadAndShare } from '@send-frontend/apps/send/composables/useUploa
1111
import useFolderStore from '@send-frontend/apps/send/stores/folder-store';
1212
1313
import BackupAndRestore from '@send-frontend/apps/common/BackupAndRestore.vue';
14+
import WithLoader from '@send-frontend/apps/common/WithLoader.vue';
15+
import PromptPopupLogin from '@send-frontend/apps/send/views/PromptLogin.vue';
16+
import { useAuth } from '@send-frontend/lib/auth';
1417
import {
1518
ALL_UPLOADS_ABORTED,
1619
FIFTEEN_MINUTES,
@@ -25,11 +28,6 @@ import useApiStore from '@send-frontend/stores/api-store';
2528
import { useQuery } from '@tanstack/vue-query';
2629
import UploadPage from '../pages/UploadPage.vue';
2730
import { useStatusStore } from '../stores/status-store';
28-
import { useAuth } from '@send-frontend/lib/auth';
29-
import WithLoader from '@send-frontend/apps/common/WithLoader.vue';
30-
import PromptPopupLogin from '@send-frontend/apps/send/views/PromptLogin.vue';
31-
import { useAuthStore } from '@send-frontend/stores';
32-
import { useSendConfig } from '@send-frontend/composables/useSendConfig';
3331
3432
interface FileItem {
3533
id: number;

packages/send/frontend/src/composables/useSendConfig.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,16 @@ export function useSendConfig() {
3333
/**
3434
* Checks browser extension storage for SEND_MESSAGE_TO_BRIDGE value
3535
* and transfers it to localStorage under 'lb/passphrase' key.
36-
* The value is stored as an object with passPhrase property.
36+
* The value is stored in the encrypted AES-GCM format.
3737
*/
3838
const checkAndTransferBridgeMessage = async () => {
3939
try {
4040
const result = await browser.storage.local.get(SEND_MESSAGE_TO_BRIDGE);
4141

4242
if (result[SEND_MESSAGE_TO_BRIDGE]) {
4343
const value = result[SEND_MESSAGE_TO_BRIDGE];
44-
const passphraseObject = {
45-
passPhrase: value,
46-
};
4744

48-
localStorage.setItem('lb/passphrase', JSON.stringify(passphraseObject));
45+
await keychain.storePassPhrase(value);
4946
console.log('✅ Transferred bridge message to localStorage');
5047

5148
// Delete the value from extension storage after successful transfer
@@ -200,7 +197,7 @@ export function useSendConfig() {
200197
useLoginQuery,
201198
/**
202199
* Checks browser extension storage for SEND_MESSAGE_TO_BRIDGE value
203-
* and transfers it to localStorage under 'lb/passphrase' key.
200+
* and transfers it to localStorage under 'lb/passphrase' key in encrypted form.
204201
*/
205202
checkAndTransferBridgeMessage,
206203
/**

packages/send/frontend/src/lib/keychain.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,10 @@ export class Keychain {
491491

492492
// load other keys
493493
this.keys = await this.fallbackToStoredKeys(keys);
494+
495+
// decrypt and cache the passphrase from storage
496+
await this._storage.initializePassphrase();
497+
494498
return true;
495499
// eslint-disable-next-line @typescript-eslint/no-unused-vars
496500
} catch (e) {

packages/send/frontend/src/lib/storage/index.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { JwkKeyPair, StoredKey } from '@send-frontend/lib/keychain';
22
import { UserType } from '@send-frontend/types';
33
import LocalStorageAdapter from './LocalStorage';
4+
import {
5+
decryptPassphrase,
6+
encryptPassphrase,
7+
EncryptedPassphrase,
8+
getOrCreatePassphraseKey,
9+
} from './passphraseEncryption';
410

511
export interface StorageAdapter {
612
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -10,6 +16,10 @@ export interface StorageAdapter {
1016
clear: () => void;
1117
}
1218

19+
// Kept out of the class to avoid changing the structural type of Storage,
20+
// which would break test mocks that construct Keychain-shaped objects.
21+
const passPhraseCache = new WeakMap<Storage, string>();
22+
1323
export class Storage {
1424
USER_KEY = 'lb/user';
1525
OTHER_KEYS_KEY = 'lb/keys';
@@ -21,6 +31,39 @@ export class Storage {
2131
this.adapter = new Adapter();
2232
}
2333

34+
/**
35+
* Must be awaited before any call to getPassPhrase().
36+
* Reads the stored passphrase, decrypts it if it is in the encrypted
37+
* AES-GCM format, or migrates the legacy plaintext value by re-encrypting it.
38+
*/
39+
async initializePassphrase(): Promise<void> {
40+
const stored = this.adapter.get(this.PASS_PHRASE);
41+
if (!stored) return;
42+
43+
// Legacy plaintext format: { passPhrase: "..." }
44+
if (stored.passPhrase !== undefined) {
45+
const plain: string = stored.passPhrase ?? '';
46+
passPhraseCache.set(this, plain);
47+
// Migrate to encrypted format in place
48+
if (plain) {
49+
await this.storePassPhrase(plain);
50+
}
51+
return;
52+
}
53+
54+
// Encrypted format: { iv: [...], data: [...] }
55+
if (stored.iv !== undefined && stored.data !== undefined) {
56+
const key = await getOrCreatePassphraseKey();
57+
if (key) {
58+
const plain = await decryptPassphrase(
59+
key,
60+
stored as EncryptedPassphrase
61+
);
62+
passPhraseCache.set(this, plain);
63+
}
64+
}
65+
}
66+
2467
async storeUser(userObj: UserType): Promise<void> {
2568
this.adapter.set(this.USER_KEY, { ...userObj });
2669
}
@@ -34,12 +77,19 @@ export class Storage {
3477
}
3578

3679
async storePassPhrase(passPhrase: string): Promise<void> {
37-
this.adapter.set(this.PASS_PHRASE, { passPhrase });
80+
const key = await getOrCreatePassphraseKey();
81+
if (key) {
82+
const encrypted = await encryptPassphrase(key, passPhrase);
83+
this.adapter.set(this.PASS_PHRASE, encrypted);
84+
} else {
85+
// Fallback for environments without IndexedDB (e.g. tests)
86+
this.adapter.set(this.PASS_PHRASE, { passPhrase });
87+
}
88+
passPhraseCache.set(this, passPhrase);
3889
}
3990

4091
getPassPhrase(): string {
41-
const keys = this.adapter.get(this.PASS_PHRASE);
42-
return keys?.passPhrase || '';
92+
return passPhraseCache.get(this) ?? '';
4393
}
4494

4595
async loadKeys(): Promise<StoredKey> {
@@ -55,6 +105,7 @@ export class Storage {
55105
}
56106

57107
async clear(): Promise<void> {
108+
passPhraseCache.delete(this);
58109
return this.adapter.clear();
59110
}
60111

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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

Comments
 (0)