argon2-crypto.ts raw
1 /**
2 * Secure vault encryption/decryption using Argon2id + AES-GCM
3 *
4 * - Argon2id key derivation with ~3 second computation time
5 * - AES-256-GCM authenticated encryption
6 * - Random 32-byte salt per vault
7 * - Random 12-byte IV per encryption
8 *
9 * Note: Uses main thread for Argon2id (via WebAssembly) because Web Workers
10 * in browser extensions cannot load external scripts due to CSP restrictions.
11 * The deriving modal provides user feedback during the ~3 second derivation.
12 */
13
14 import { argon2id } from 'hash-wasm';
15 import { Buffer } from 'buffer';
16
17 // Argon2id parameters tuned for ~3 second derivation on typical hardware
18 const ARGON2_CONFIG = {
19 parallelism: 4, // 4 threads
20 iterations: 8, // Time cost
21 memorySize: 262144, // 256 MB memory
22 hashLength: 32, // 256-bit key for AES-256
23 outputType: 'binary' as const,
24 };
25
26 /**
27 * Derive an encryption key from password using Argon2id
28 * @param password - User's password
29 * @param salt - Random 32-byte salt
30 * @returns 32-byte derived key
31 */
32 export async function deriveKeyArgon2(
33 password: string,
34 salt: Uint8Array
35 ): Promise<Uint8Array> {
36 // Use hash-wasm's argon2id (WebAssembly-based, runs on main thread)
37 // This blocks the UI for ~3 seconds, which is why we show a modal
38 const result = await argon2id({
39 password: password,
40 salt: salt,
41 ...ARGON2_CONFIG,
42 });
43 return result;
44 }
45
46 /**
47 * Generate a random salt for Argon2id
48 * @returns Base64 encoded 32-byte salt
49 */
50 export function generateSalt(): string {
51 const salt = crypto.getRandomValues(new Uint8Array(32));
52 return Buffer.from(salt).toString('base64');
53 }
54
55 /**
56 * Generate a random IV for AES-GCM
57 * @returns Base64 encoded 12-byte IV
58 */
59 export function generateIV(): string {
60 const iv = crypto.getRandomValues(new Uint8Array(12));
61 return Buffer.from(iv).toString('base64');
62 }
63
64 /**
65 * Encrypt data using Argon2id-derived key + AES-256-GCM
66 * @param plaintext - Data to encrypt
67 * @param password - User's password
68 * @param saltBase64 - Base64 encoded 32-byte salt
69 * @param ivBase64 - Base64 encoded 12-byte IV
70 * @returns Base64 encoded ciphertext
71 */
72 export async function encryptWithArgon2(
73 plaintext: string,
74 password: string,
75 saltBase64: string,
76 ivBase64: string
77 ): Promise<string> {
78 const salt = Buffer.from(saltBase64, 'base64');
79 const iv = Buffer.from(ivBase64, 'base64');
80
81 // Derive key using Argon2id (~3 seconds, in worker)
82 const keyBytes = await deriveKeyArgon2(password, salt);
83
84 // Import key for AES-GCM
85 const key = await crypto.subtle.importKey(
86 'raw',
87 keyBytes,
88 { name: 'AES-GCM' },
89 false,
90 ['encrypt']
91 );
92
93 // Encrypt the data
94 const encoder = new TextEncoder();
95 const encrypted = await crypto.subtle.encrypt(
96 { name: 'AES-GCM', iv: iv },
97 key,
98 encoder.encode(plaintext)
99 );
100
101 return Buffer.from(encrypted).toString('base64');
102 }
103
104 /**
105 * Decrypt data using Argon2id-derived key + AES-256-GCM
106 * @param ciphertextBase64 - Base64 encoded ciphertext
107 * @param password - User's password
108 * @param saltBase64 - Base64 encoded 32-byte salt
109 * @param ivBase64 - Base64 encoded 12-byte IV
110 * @returns Decrypted plaintext
111 * @throws Error if password is wrong or data is corrupted
112 */
113 export async function decryptWithArgon2(
114 ciphertextBase64: string,
115 password: string,
116 saltBase64: string,
117 ivBase64: string
118 ): Promise<string> {
119 const salt = Buffer.from(saltBase64, 'base64');
120 const iv = Buffer.from(ivBase64, 'base64');
121 const ciphertext = Buffer.from(ciphertextBase64, 'base64');
122
123 // Derive key using Argon2id (~3 seconds, in worker)
124 const keyBytes = await deriveKeyArgon2(password, salt);
125
126 // Import key for AES-GCM
127 const key = await crypto.subtle.importKey(
128 'raw',
129 keyBytes,
130 { name: 'AES-GCM' },
131 false,
132 ['decrypt']
133 );
134
135 // Decrypt
136 let decrypted;
137 try {
138 decrypted = await crypto.subtle.decrypt(
139 { name: 'AES-GCM', iv: iv },
140 key,
141 ciphertext
142 );
143 } catch {
144 throw new Error('Decryption failed - invalid password or corrupted data');
145 }
146
147 const decoder = new TextDecoder();
148 return decoder.decode(decrypted);
149 }
150
151