Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/lib/encryption/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CACHE_LIMITS,
ENCODING,
} from './constants';
import { secureStorage } from './secureStorage';

export interface EncryptedNote {
encryptedTitle: string;
Expand Down Expand Up @@ -177,33 +178,38 @@ class EncryptionService {
}
}

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

// Check memory cache first (for performance)
if (this.userSecrets.has(userId)) {
return this.userSecrets.get(userId)!;
}

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

if (!secret) {
// Generate new random secret and store it securely
const randomBytes = crypto.getRandomValues(new Uint8Array(64));
secret = this.arrayBufferToBase64(randomBytes);
localStorage.setItem(storageKey, secret);
await secureStorage.setSecureItem(storageKey, secret);
}

// Cache in memory for performance (cleared on page reload for security)
this.userSecrets.set(userId, secret);
return secret;
}

async deriveKey(userId: string, salt: Uint8Array): Promise<CryptoKey> {
const encoder = new TextEncoder();
const userSecret = this.getUserSecret(userId);
const userSecret = await this.getUserSecret(userId);

if (
this.masterPasswordMode &&
Expand Down Expand Up @@ -413,15 +419,22 @@ class EncryptionService {
clearKeys(): void {
this.decryptCache.clear();
this.userSecrets.clear();
// Clear secure storage session for additional security
secureStorage.clearSession();
}

clearUserData(userId: string): void {
// Clear all encryption keys
localStorage.removeItem(STORAGE_KEYS.USER_SECRET(userId));
// Clear all encryption keys from regular localStorage
localStorage.removeItem(`enc_master_key_${userId}`);
localStorage.removeItem(`has_master_password_${userId}`);
localStorage.removeItem(`test_encryption_${userId}`);

// Clear secure storage (encrypted user secrets)
secureStorage.removeSecureItem(STORAGE_KEYS.USER_SECRET(userId));

// Also remove any legacy plain-text secrets
localStorage.removeItem(STORAGE_KEYS.USER_SECRET(userId));

// Clear from memory
this.userSecrets.delete(userId);
this.masterPasswordMode = false;
Expand Down
131 changes: 131 additions & 0 deletions src/lib/encryption/secureStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Secure storage wrapper to encrypt sensitive data before storing in localStorage
import { ENCRYPTION_CONFIG } from './constants';

class SecureStorage {
private sessionKey: CryptoKey | null = null;
private isInitialized = false;

async initialize(): Promise<void> {
if (this.isInitialized) return;

// Generate a session-based encryption key
// This key exists only in memory and is lost on page reload
this.sessionKey = await crypto.subtle.generateKey(
{
name: ENCRYPTION_CONFIG.ALGORITHM,
length: ENCRYPTION_CONFIG.KEY_LENGTH,
},
false, // Not extractable for security
['encrypt', 'decrypt']
);

this.isInitialized = true;
}

async setSecureItem(key: string, value: string): Promise<void> {
if (!this.sessionKey) {
await this.initialize();
}

try {
const encoder = new TextEncoder();
const data = encoder.encode(value);

// Generate random IV for each encryption
const iv = crypto.getRandomValues(new Uint8Array(ENCRYPTION_CONFIG.IV_LENGTH));

// Encrypt the data
const encryptedData = await crypto.subtle.encrypt(
{ name: ENCRYPTION_CONFIG.ALGORITHM, iv },
this.sessionKey!,
data
);

// Store encrypted data + IV
const payload = {
encrypted: this.arrayBufferToBase64(encryptedData),
iv: this.arrayBufferToBase64(iv),
};

localStorage.setItem(key, JSON.stringify(payload));
} catch (error) {
console.error('Failed to encrypt storage item:', error);
throw new Error('Secure storage encryption failed');
}
}

async getSecureItem(key: string): Promise<string | null> {
if (!this.sessionKey) {
await this.initialize();
}

try {
const stored = localStorage.getItem(key);
if (!stored) return null;

const payload = JSON.parse(stored);
if (!payload.encrypted || !payload.iv) {
// Handle legacy plain-text storage by removing it
localStorage.removeItem(key);
return null;
}

// Decrypt the data
const encryptedData = this.base64ToArrayBuffer(payload.encrypted);
const iv = this.base64ToUint8Array(payload.iv);

const decryptedBuffer = await crypto.subtle.decrypt(
{ name: ENCRYPTION_CONFIG.ALGORITHM, iv },
this.sessionKey!,
encryptedData
);

const decoder = new TextDecoder();
return decoder.decode(decryptedBuffer);
} catch (error) {
console.error('Failed to decrypt storage item:', error);
// If decryption fails, remove the corrupted item
localStorage.removeItem(key);
return null;
}
}

removeSecureItem(key: string): void {
localStorage.removeItem(key);
}

// Clear all session keys on logout/cleanup
clearSession(): void {
this.sessionKey = null;
this.isInitialized = false;
}

private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunks: string[] = [];
const CHUNK_SIZE = 8192;

for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length));
chunks.push(String.fromCharCode.apply(null, Array.from(chunk)));
}

return btoa(chunks.join(''));
}

private base64ToArrayBuffer(base64: string): ArrayBuffer {
const bytes = this.base64ToUint8Array(base64);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}

private base64ToUint8Array(base64: string): Uint8Array {
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;
}
}

export const secureStorage = new SecureStorage();