Skip to content

Commit 2400dae

Browse files
author
Jarvis
committed
Implement v3 separated storage architecture
BREAKING CHANGE: Storage format upgraded to v3 with automatic migration from v2 ## Architecture Changes vault_meta (rarely changes - survives data corruption): - salt: PBKDF2 salt, never changes after creation - passwordHash: Quick password verification - createdAt/passwordChangedAt timestamps vault_data (changes every save): - iv: New each save (AES-GCM requirement) - data: Encrypted vault content - savedAt timestamp vault_backup (safety net): - Copy of vault_data before each save ## Benefits 1. Password never 'stops working' from data corruption - Salt stored separately, survives vault_data corruption 2. Clear error distinction - Wrong password: Fast verification fails - Corrupted data: Password verified but decryption fails 3. Automatic backup recovery - If vault_data corrupts, tries vault_backup automatically 4. Manual recovery possible - With password + salt, can decrypt backup even if code has bugs ## Migration - v2 vaults automatically migrate to v3 on first unlock - Uses existing salt, generates passwordHash - Removes old vault key after successful migration ## New Functions (pbkdf2.ts) - generatePasswordHash(): Creates verification hash - verifyPassword(): Quick password check - encryptWithKey(): Encrypt with existing derived key - decryptWithKey(): Decrypt with existing derived key ## Files Changed - src/utils/constants.ts: Added VAULT_META, VAULT_DATA, VAULT_BACKUP keys - src/types/index.ts: Added VaultMeta, VaultDataEncrypted types - src/crypto/pbkdf2.ts: Added password verification functions - src/storage/vault.ts: Complete rewrite for v3 architecture
1 parent faa3a1b commit 2400dae

File tree

4 files changed

+481
-185
lines changed

4 files changed

+481
-185
lines changed

src/crypto/pbkdf2.ts

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
11
/**
22
* Quack - PBKDF2 Password Derivation
3+
*
4+
* ARCHITECTURE (v3 - Separated Storage):
5+
*
6+
* vault_meta (rarely changes):
7+
* - salt: For key derivation
8+
* - passwordHash: Quick password verification
9+
*
10+
* vault_data (changes every save):
11+
* - iv: New each save (required for AES-GCM security)
12+
* - data: Encrypted vault content
13+
*
14+
* This separation means:
15+
* 1. Salt survives data corruption
16+
* 2. We can distinguish "wrong password" from "corrupted data"
17+
* 3. Recovery is more likely if only vault_data is damaged
318
*/
419

520
import { base64Encode, base64Decode, bufferToUint8Array } from '@/utils/helpers';
21+
22+
// Re-export for vault.ts
23+
export { base64Encode };
624
import { PBKDF2_ITERATIONS } from '@/utils/constants';
725

826
/**
@@ -47,7 +65,124 @@ export function generateSalt(): Uint8Array {
4765
}
4866

4967
/**
50-
* Encrypt vault data with password-derived key
68+
* Generate a password verification hash
69+
* Uses a different derivation path than encryption to avoid leaking key info
70+
*
71+
* @param password - User's master password
72+
* @param salt - The vault's salt (base64)
73+
* @returns Base64-encoded hash for storage
74+
*/
75+
export async function generatePasswordHash(
76+
password: string,
77+
salt: string
78+
): Promise<string> {
79+
// Use "verify:" prefix to create different derivation path
80+
const verifySalt = new TextEncoder().encode('verify:' + salt);
81+
82+
const passwordKey = await crypto.subtle.importKey(
83+
'raw',
84+
new TextEncoder().encode(password),
85+
'PBKDF2',
86+
false,
87+
['deriveBits']
88+
);
89+
90+
// Derive 256 bits for verification
91+
const hashBits = await crypto.subtle.deriveBits(
92+
{
93+
name: 'PBKDF2',
94+
salt: verifySalt.buffer as ArrayBuffer,
95+
iterations: PBKDF2_ITERATIONS,
96+
hash: 'SHA-256',
97+
},
98+
passwordKey,
99+
256
100+
);
101+
102+
return base64Encode(new Uint8Array(hashBits));
103+
}
104+
105+
/**
106+
* Verify password against stored hash
107+
*
108+
* @param password - Password to verify
109+
* @param salt - The vault's salt (base64)
110+
* @param storedHash - The stored password hash (base64)
111+
* @returns true if password is correct
112+
*/
113+
export async function verifyPassword(
114+
password: string,
115+
salt: string,
116+
storedHash: string
117+
): Promise<boolean> {
118+
const computedHash = await generatePasswordHash(password, salt);
119+
// Constant-time comparison would be ideal, but for client-side this is fine
120+
return computedHash === storedHash;
121+
}
122+
123+
/**
124+
* Encrypt data with an existing derived key (no new salt)
125+
* Used for saving vault data without regenerating salt
126+
*
127+
* @param data - Plaintext JSON string
128+
* @param key - Already-derived CryptoKey
129+
* @returns IV and encrypted data (both base64)
130+
*/
131+
export async function encryptWithKey(
132+
data: string,
133+
key: CryptoKey
134+
): Promise<{ iv: string; encrypted: string }> {
135+
const iv = crypto.getRandomValues(new Uint8Array(12));
136+
const encoded = new TextEncoder().encode(data);
137+
138+
const encrypted = await crypto.subtle.encrypt(
139+
{ name: 'AES-GCM', iv },
140+
key,
141+
encoded
142+
);
143+
144+
return {
145+
iv: base64Encode(iv),
146+
encrypted: base64Encode(bufferToUint8Array(encrypted)),
147+
};
148+
}
149+
150+
/**
151+
* Decrypt data with an existing derived key
152+
*
153+
* @param encrypted - Encrypted data (base64)
154+
* @param iv - Initialization vector (base64)
155+
* @param key - Already-derived CryptoKey
156+
* @returns Decrypted string or null if decryption fails
157+
*/
158+
export async function decryptWithKey(
159+
encrypted: string,
160+
iv: string,
161+
key: CryptoKey
162+
): Promise<string | null> {
163+
try {
164+
const ivBytes = base64Decode(iv);
165+
const encryptedBytes = base64Decode(encrypted);
166+
167+
const decrypted = await crypto.subtle.decrypt(
168+
{ name: 'AES-GCM', iv: ivBytes.buffer as ArrayBuffer },
169+
key,
170+
encryptedBytes.buffer as ArrayBuffer
171+
);
172+
173+
return new TextDecoder().decode(decrypted);
174+
} catch {
175+
return null;
176+
}
177+
}
178+
179+
// ============================================================================
180+
// Legacy functions (for v2 migration and backward compatibility)
181+
// ============================================================================
182+
183+
/**
184+
* Encrypt vault data with password-derived key (LEGACY - generates new salt)
185+
* @deprecated Use encryptWithKey() with separated storage instead
51186
*/
52187
export async function encryptVault(
53188
data: string,
@@ -73,7 +208,8 @@ export async function encryptVault(
73208
}
74209

75210
/**
76-
* Decrypt vault data with password-derived key
211+
* Decrypt vault data with password-derived key (LEGACY)
212+
* @deprecated Use decryptWithKey() with separated storage instead
77213
*/
78214
export async function decryptVault(
79215
encrypted: string,
@@ -95,7 +231,7 @@ export async function decryptVault(
95231
);
96232

97233
return new TextDecoder().decode(decrypted);
98-
} catch (error) {
234+
} catch {
99235
// Wrong password or corrupted data
100236
return null;
101237
}

0 commit comments

Comments
 (0)