Skip to content

Commit 3ddbbc4

Browse files
authored
Merge pull request #2 from typelets/security/fix-cleartext-storage
fix: resolve clear-text storage of encryption secrets
2 parents 13f2856 + 2d43ceb commit 3ddbbc4

File tree

2 files changed

+150
-6
lines changed

2 files changed

+150
-6
lines changed

src/lib/encryption/index.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
CACHE_LIMITS,
55
ENCODING,
66
} from './constants';
7+
import { secureStorage } from './secureStorage';
78

89
export interface EncryptedNote {
910
encryptedTitle: string;
@@ -177,33 +178,38 @@ class EncryptionService {
177178
}
178179
}
179180

180-
private getUserSecret(userId: string): string {
181+
private async getUserSecret(userId: string): Promise<string> {
182+
// Master password mode takes precedence - these are already derived keys
181183
const masterKey = localStorage.getItem(`enc_master_key_${userId}`);
182184
if (masterKey) {
183185
this.masterPasswordMode = true;
184186
return masterKey;
185187
}
186188

189+
// Check memory cache first (for performance)
187190
if (this.userSecrets.has(userId)) {
188191
return this.userSecrets.get(userId)!;
189192
}
190193

194+
// Use secure storage for fallback user secrets (when no master password)
191195
const storageKey = STORAGE_KEYS.USER_SECRET(userId);
192-
let secret = localStorage.getItem(storageKey);
196+
let secret = await secureStorage.getSecureItem(storageKey);
193197

194198
if (!secret) {
199+
// Generate new random secret and store it securely
195200
const randomBytes = crypto.getRandomValues(new Uint8Array(64));
196201
secret = this.arrayBufferToBase64(randomBytes);
197-
localStorage.setItem(storageKey, secret);
202+
await secureStorage.setSecureItem(storageKey, secret);
198203
}
199204

205+
// Cache in memory for performance (cleared on page reload for security)
200206
this.userSecrets.set(userId, secret);
201207
return secret;
202208
}
203209

204210
async deriveKey(userId: string, salt: Uint8Array): Promise<CryptoKey> {
205211
const encoder = new TextEncoder();
206-
const userSecret = this.getUserSecret(userId);
212+
const userSecret = await this.getUserSecret(userId);
207213

208214
if (
209215
this.masterPasswordMode &&
@@ -413,15 +419,22 @@ class EncryptionService {
413419
clearKeys(): void {
414420
this.decryptCache.clear();
415421
this.userSecrets.clear();
422+
// Clear secure storage session for additional security
423+
secureStorage.clearSession();
416424
}
417425

418426
clearUserData(userId: string): void {
419-
// Clear all encryption keys
420-
localStorage.removeItem(STORAGE_KEYS.USER_SECRET(userId));
427+
// Clear all encryption keys from regular localStorage
421428
localStorage.removeItem(`enc_master_key_${userId}`);
422429
localStorage.removeItem(`has_master_password_${userId}`);
423430
localStorage.removeItem(`test_encryption_${userId}`);
424431

432+
// Clear secure storage (encrypted user secrets)
433+
secureStorage.removeSecureItem(STORAGE_KEYS.USER_SECRET(userId));
434+
435+
// Also remove any legacy plain-text secrets
436+
localStorage.removeItem(STORAGE_KEYS.USER_SECRET(userId));
437+
425438
// Clear from memory
426439
this.userSecrets.delete(userId);
427440
this.masterPasswordMode = false;
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Secure storage wrapper to encrypt sensitive data before storing in localStorage
2+
import { ENCRYPTION_CONFIG } from './constants';
3+
4+
class SecureStorage {
5+
private sessionKey: CryptoKey | null = null;
6+
private isInitialized = false;
7+
8+
async initialize(): Promise<void> {
9+
if (this.isInitialized) return;
10+
11+
// Generate a session-based encryption key
12+
// This key exists only in memory and is lost on page reload
13+
this.sessionKey = await crypto.subtle.generateKey(
14+
{
15+
name: ENCRYPTION_CONFIG.ALGORITHM,
16+
length: ENCRYPTION_CONFIG.KEY_LENGTH,
17+
},
18+
false, // Not extractable for security
19+
['encrypt', 'decrypt']
20+
);
21+
22+
this.isInitialized = true;
23+
}
24+
25+
async setSecureItem(key: string, value: string): Promise<void> {
26+
if (!this.sessionKey) {
27+
await this.initialize();
28+
}
29+
30+
try {
31+
const encoder = new TextEncoder();
32+
const data = encoder.encode(value);
33+
34+
// Generate random IV for each encryption
35+
const iv = crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.IV_LENGTH));
36+
37+
// Encrypt the data
38+
const encryptedData = await crypto.subtle.encrypt(
39+
{ name: ENCRYPTION_CONFIG.ALGORITHM, iv },
40+
this.sessionKey!,
41+
data
42+
);
43+
44+
// Store encrypted data + IV
45+
const payload = {
46+
encrypted: this.arrayBufferToBase64(encryptedData),
47+
iv: this.arrayBufferToBase64(iv),
48+
};
49+
50+
localStorage.setItem(key, JSON.stringify(payload));
51+
} catch (error) {
52+
console.error('Failed to encrypt storage item:', error);
53+
throw new Error('Secure storage encryption failed');
54+
}
55+
}
56+
57+
async getSecureItem(key: string): Promise<string | null> {
58+
if (!this.sessionKey) {
59+
await this.initialize();
60+
}
61+
62+
try {
63+
const stored = localStorage.getItem(key);
64+
if (!stored) return null;
65+
66+
const payload = JSON.parse(stored);
67+
if (!payload.encrypted || !payload.iv) {
68+
// Handle legacy plain-text storage by removing it
69+
localStorage.removeItem(key);
70+
return null;
71+
}
72+
73+
// Decrypt the data
74+
const encryptedData = this.base64ToArrayBuffer(payload.encrypted);
75+
const iv = this.base64ToUint8Array(payload.iv);
76+
77+
const decryptedBuffer = await crypto.subtle.decrypt(
78+
{ name: ENCRYPTION_CONFIG.ALGORITHM, iv },
79+
this.sessionKey!,
80+
encryptedData
81+
);
82+
83+
const decoder = new TextDecoder();
84+
return decoder.decode(decryptedBuffer);
85+
} catch (error) {
86+
console.error('Failed to decrypt storage item:', error);
87+
// If decryption fails, remove the corrupted item
88+
localStorage.removeItem(key);
89+
return null;
90+
}
91+
}
92+
93+
removeSecureItem(key: string): void {
94+
localStorage.removeItem(key);
95+
}
96+
97+
// Clear all session keys on logout/cleanup
98+
clearSession(): void {
99+
this.sessionKey = null;
100+
this.isInitialized = false;
101+
}
102+
103+
private arrayBufferToBase64(buffer: ArrayBuffer): string {
104+
const bytes = new Uint8Array(buffer);
105+
const chunks: string[] = [];
106+
const CHUNK_SIZE = 8192;
107+
108+
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
109+
const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length));
110+
chunks.push(String.fromCharCode.apply(null, Array.from(chunk)));
111+
}
112+
113+
return btoa(chunks.join(''));
114+
}
115+
116+
private base64ToArrayBuffer(base64: string): ArrayBuffer {
117+
const bytes = this.base64ToUint8Array(base64);
118+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
119+
}
120+
121+
private base64ToUint8Array(base64: string): Uint8Array {
122+
const binary = atob(base64);
123+
const bytes = new Uint8Array(binary.length);
124+
for (let i = 0; i < binary.length; i++) {
125+
bytes[i] = binary.charCodeAt(i);
126+
}
127+
return bytes;
128+
}
129+
}
130+
131+
export const secureStorage = new SecureStorage();

0 commit comments

Comments
 (0)