-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathencryption.ts
More file actions
313 lines (278 loc) · 8.98 KB
/
encryption.ts
File metadata and controls
313 lines (278 loc) · 8.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
// ═══════════════════════════════════════════════════════════════════════════════
// Client-Side Encryption Utilities
// ═══════════════════════════════════════════════════════════════════════════════
//
// Algorithm: AES-256-GCM with PBKDF2 key derivation
// - AES-256-GCM: Authenticated encryption (confidentiality + integrity)
// - PBKDF2: 600,000 iterations (OWASP 2023 recommendation)
// - Random salt per user
// - Random IV per encryption operation
//
// Security Model:
// - Server never sees plaintext credentials
// - Server never sees master password
// - Encryption/decryption happens entirely in browser
// ═══════════════════════════════════════════════════════════════════════════════
import { logger } from './logger';
const PBKDF2_ITERATIONS = 600000; // OWASP 2023 recommendation
const SALT_LENGTH = 16; // 128 bits
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
const KEY_LENGTH = 256; // AES-256
/**
* Convert ArrayBuffer to Base64 string for storage/transmission
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert Base64 string back to ArrayBuffer
*/
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
/**
* Generate cryptographically secure random bytes
*/
function generateRandomBytes(length: number): Uint8Array {
return crypto.getRandomValues(new Uint8Array(length));
}
/**
* Generate a random salt for key derivation
*/
export function generateSalt(): string {
const salt = generateRandomBytes(SALT_LENGTH);
return arrayBufferToBase64(salt.buffer);
}
/**
* Generate a recovery key (base58-encoded for user-friendliness)
*/
export function generateRecoveryKey(): string {
const randomBytes = generateRandomBytes(32); // 256 bits
const base64 = arrayBufferToBase64(randomBytes.buffer);
// Format as groups for readability: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
const cleaned = base64.replace(/[+/=]/g, '').substring(0, 24);
return cleaned.match(/.{1,4}/g)?.join('-') || cleaned;
}
/**
* Derive a cryptographic key from user's password using PBKDF2
*
* @param password - User's master password
* @param salt - Base64-encoded salt (generated once per user)
* @returns CryptoKey suitable for AES-256-GCM
*/
export async function deriveKeyFromPassword(
password: string,
salt: string
): Promise<CryptoKey> {
// Convert password to key material
const encoder = new TextEncoder();
const passwordBuffer = encoder.encode(password);
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBuffer,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
// Derive AES-256 key using PBKDF2
const saltBuffer = base64ToArrayBuffer(salt);
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBuffer,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256'
},
keyMaterial,
{
name: 'AES-GCM',
length: KEY_LENGTH
},
false, // Not extractable for security
['encrypt', 'decrypt']
);
return key;
}
/**
* Encrypt plaintext using AES-256-GCM
*
* @param plaintext - Data to encrypt
* @param key - CryptoKey from deriveKeyFromPassword()
* @returns Object with IV and encrypted data (both base64-encoded)
*/
export async function encryptData(
plaintext: string,
key: CryptoKey
): Promise<{ iv: string; ciphertext: string }> {
if (!plaintext || plaintext.trim() === '') {
throw new Error('Cannot encrypt empty data');
}
// Generate random IV for this encryption
const iv = generateRandomBytes(IV_LENGTH);
// Encrypt
const encoder = new TextEncoder();
const plaintextBuffer = encoder.encode(plaintext);
const ciphertextBuffer = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
plaintextBuffer
);
return {
iv: arrayBufferToBase64(iv.buffer),
ciphertext: arrayBufferToBase64(ciphertextBuffer)
};
}
/**
* Decrypt ciphertext using AES-256-GCM
*
* @param ciphertext - Base64-encoded encrypted data
* @param iv - Base64-encoded initialization vector
* @param key - CryptoKey from deriveKeyFromPassword()
* @returns Decrypted plaintext string
*/
export async function decryptData(
ciphertext: string,
iv: string,
key: CryptoKey
): Promise<string> {
if (!ciphertext || !iv) {
throw new Error('Cannot decrypt: missing ciphertext or IV');
}
try {
const ciphertextBuffer = base64ToArrayBuffer(ciphertext);
const ivBuffer = base64ToArrayBuffer(iv);
const plaintextBuffer = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: ivBuffer
},
key,
ciphertextBuffer
);
const decoder = new TextDecoder();
return decoder.decode(plaintextBuffer);
} catch (error) {
console.error('Decryption failed:', error);
throw new Error('Decryption failed: Invalid key or corrupted data');
}
}
/**
* Encrypt an object's sensitive fields
* Returns encrypted version with _encrypted suffix fields
*/
export async function encryptCredentialFields(
fields: {
username?: string;
password?: string;
email?: string;
notes?: string;
recovery_codes?: string;
},
key: CryptoKey
): Promise<{
username_encrypted?: string;
username_iv?: string;
password_encrypted?: string;
password_iv?: string;
email_encrypted?: string;
email_iv?: string;
notes_encrypted?: string;
notes_iv?: string;
recovery_codes_encrypted?: string;
recovery_codes_iv?: string;
}> {
const result: Record<string, string> = {};
for (const [fieldName, value] of Object.entries(fields)) {
if (value && value.trim() !== '') {
const encrypted = await encryptData(value, key);
result[`${fieldName}_encrypted`] = encrypted.ciphertext;
result[`${fieldName}_iv`] = encrypted.iv;
}
}
return result;
}
/**
* Encrypted credential data structure from the API
* Allows null to support API responses where fields may be explicitly null
*/
interface EncryptedCredentialData {
username_encrypted?: string | null;
username_iv?: string | null;
password_encrypted?: string | null;
password_iv?: string | null;
email_encrypted?: string | null;
email_iv?: string | null;
notes_encrypted?: string | null;
notes_iv?: string | null;
recovery_codes_encrypted?: string | null;
recovery_codes_iv?: string | null;
}
/**
* Decrypt an object's encrypted fields
*
* Optimized: Decrypts all fields in parallel for better performance
*/
export async function decryptCredentialFields(
encryptedData: EncryptedCredentialData,
key: CryptoKey
): Promise<{
username?: string;
password?: string;
email?: string;
notes?: string;
recovery_codes?: string;
}> {
const fieldNames = ['username', 'password', 'email', 'notes', 'recovery_codes'] as const;
type FieldName = typeof fieldNames[number];
type EncryptedKey = `${FieldName}_encrypted`;
type IvKey = `${FieldName}_iv`;
// Decrypt all fields in parallel for better performance
const decryptionPromises = fieldNames.map(async (fieldName) => {
const encryptedKey = `${fieldName}_encrypted` as EncryptedKey;
const ivKey = `${fieldName}_iv` as IvKey;
const ciphertext = encryptedData[encryptedKey];
const iv = encryptedData[ivKey];
// Check that both ciphertext and iv are non-null strings
if (ciphertext && iv) {
try {
const value = await decryptData(ciphertext, iv, key);
return { fieldName, value };
} catch (error) {
logger.warn(`Failed to decrypt ${fieldName}:`, error);
return { fieldName, value: undefined };
}
}
return { fieldName, value: undefined };
});
const results = await Promise.all(decryptionPromises);
// Convert array of results to object
const result: Record<string, string> = {};
for (const { fieldName, value } of results) {
if (value !== undefined) {
result[fieldName] = value;
}
}
return result;
}
/**
* Store user's encryption key in session storage (memory-only)
* WARNING: Key is lost when browser closes - this is intentional for security
*/
export function storeMasterKey(_key: CryptoKey): void {
// We can't directly store CryptoKey, so we'll store the password temporarily
// In a production app, you'd use a more sophisticated key management system
// Note: Master key is stored in memory only (CryptoContext.tsx)
}