Security fixes: swarm signature verification and error handling
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Secure Key Persistence
|
||||
*
|
||||
* Stores the encrypted private key in IndexedDB so the user stays unlocked
|
||||
* across page refreshes and tabs. The key is wrapped with a session key
|
||||
* that's stored in localStorage (cleared on browser close/logout).
|
||||
*
|
||||
* Security model:
|
||||
* - Private key is ALWAYS encrypted at rest (IndexedDB)
|
||||
* - Session key in localStorage is needed to unwrap
|
||||
* - XSS attacker needs BOTH storage access AND the session key
|
||||
* - On logout, both are cleared
|
||||
*/
|
||||
|
||||
import { deserializeEncryptedKey, type EncryptedPrivateKey } from './private-key-client';
|
||||
|
||||
const DB_NAME = 'synapsis-identity';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'keys';
|
||||
const SESSION_KEY_ITEM = 'synapsis_session_key';
|
||||
const WRAPPED_KEY_ITEM = 'synapsis_wrapped_key';
|
||||
|
||||
interface WrappedKey {
|
||||
wrapped: string; // Base64 of wrapped key
|
||||
iv: string; // Base64 of IV
|
||||
salt: string; // Base64 of salt
|
||||
createdAt: number; // Timestamp for expiry
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
key: string; // Base64 of session encryption key
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// IndexedDB Operations
|
||||
// ============================================
|
||||
|
||||
async function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function storeInDB(key: string, value: any): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(value, key);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function getFromDB<T>(key: string): Promise<T | null> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function removeFromDB(key: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(key);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Crypto Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Derive a session key from the user's password
|
||||
* This is fast and deterministic - same password = same session key
|
||||
*/
|
||||
export async function deriveSessionKey(password: string): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder();
|
||||
const passwordData = encoder.encode(password);
|
||||
|
||||
// Import password as key material
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
passwordData,
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
// Derive a session key - using a fixed salt since we want deterministic output
|
||||
// The salt is public knowledge anyway (stored with wrapped key)
|
||||
const fixedSalt = encoder.encode('synapsis-session-v1');
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: fixedSalt,
|
||||
iterations: 10000, // Lower than main encryption since this is session-only
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, // extractable so we can store it
|
||||
['wrapKey', 'unwrapKey']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random session key (for when we already have the decrypted key)
|
||||
*/
|
||||
async function generateSessionKey(): Promise<CryptoKey> {
|
||||
return crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['wrapKey', 'unwrapKey']
|
||||
);
|
||||
}
|
||||
|
||||
async function exportSessionKey(key: CryptoKey): Promise<string> {
|
||||
const exported = await crypto.subtle.exportKey('raw', key);
|
||||
return arrayBufferToBase64(exported);
|
||||
}
|
||||
|
||||
async function importSessionKey(keyData: string): Promise<CryptoKey> {
|
||||
const buffer = base64ToArrayBuffer(keyData);
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
buffer,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false, // not extractable after import
|
||||
['wrapKey', 'unwrapKey']
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Key Wrapping / Unwrapping
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Wrap the raw private key data with a session key for storage
|
||||
* This works on the raw PKCS8 bytes, not the CryptoKey
|
||||
*/
|
||||
async function wrapRawPrivateKey(
|
||||
privateKeyBase64: string,
|
||||
sessionKey: CryptoKey
|
||||
): Promise<WrappedKey> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Convert base64 private key to bytes
|
||||
const privateKeyBytes = base64ToArrayBuffer(privateKeyBase64);
|
||||
|
||||
// Encrypt the raw key data using AES-GCM
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
sessionKey,
|
||||
privateKeyBytes
|
||||
);
|
||||
|
||||
return {
|
||||
wrapped: arrayBufferToBase64(encrypted),
|
||||
iv: arrayBufferToBase64(iv),
|
||||
salt: arrayBufferToBase64(new Uint8Array(0)), // Not used but kept for structure
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the private key using the session key
|
||||
* Returns the raw key bytes that can then be imported
|
||||
*/
|
||||
async function unwrapRawPrivateKey(
|
||||
wrapped: WrappedKey,
|
||||
sessionKey: CryptoKey
|
||||
): Promise<ArrayBuffer> {
|
||||
const wrappedBuffer = base64ToArrayBuffer(wrapped.wrapped);
|
||||
const iv = base64ToArrayBuffer(wrapped.iv);
|
||||
|
||||
// Decrypt the raw key data
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
sessionKey,
|
||||
wrappedBuffer
|
||||
);
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Save the unlocked private key for persistence
|
||||
* Call this after the user unlocks with their password
|
||||
*
|
||||
* Note: We wrap the raw key data (not the CryptoKey) because the imported
|
||||
* key is non-extractable for security. We have the raw PKCS8 data available
|
||||
* right after decryption, before importing.
|
||||
*/
|
||||
export async function persistUnlockedKey(
|
||||
privateKeyBase64: string,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Derive session key from password
|
||||
const sessionKey = await deriveSessionKey(password);
|
||||
|
||||
// Wrap the raw private key data (before importing as non-extractable)
|
||||
const wrapped = await wrapRawPrivateKey(privateKeyBase64, sessionKey);
|
||||
|
||||
// Store wrapped key in IndexedDB
|
||||
await storeInDB(WRAPPED_KEY_ITEM, wrapped);
|
||||
|
||||
// Store session key in localStorage (so it survives refreshes)
|
||||
const sessionKeyData = await exportSessionKey(sessionKey);
|
||||
const sessionData: SessionData = {
|
||||
key: sessionKeyData,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY_ITEM, JSON.stringify(sessionData));
|
||||
|
||||
console.log('[KeyPersistence] Key persisted successfully');
|
||||
} catch (error) {
|
||||
console.error('[KeyPersistence] Failed to persist key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to restore the private key from persistent storage
|
||||
* Returns the raw key bytes if available, null otherwise
|
||||
* The caller must then import these bytes as a non-extractable CryptoKey
|
||||
*/
|
||||
export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
// Get session key from localStorage
|
||||
const sessionDataRaw = localStorage.getItem(SESSION_KEY_ITEM);
|
||||
if (!sessionDataRaw) {
|
||||
console.log('[KeyPersistence] No session key found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionData: SessionData = JSON.parse(sessionDataRaw);
|
||||
|
||||
// Check expiry (24 hours)
|
||||
const MAX_AGE = 24 * 60 * 60 * 1000;
|
||||
if (Date.now() - sessionData.createdAt > MAX_AGE) {
|
||||
console.log('[KeyPersistence] Session expired');
|
||||
await clearPersistentKey();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get wrapped key from IndexedDB
|
||||
const wrapped = await getFromDB<WrappedKey>(WRAPPED_KEY_ITEM);
|
||||
if (!wrapped) {
|
||||
console.log('[KeyPersistence] No wrapped key found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Import session key
|
||||
const sessionKey = await importSessionKey(sessionData.key);
|
||||
|
||||
// Unwrap to get raw key bytes
|
||||
const privateKeyBytes = await unwrapRawPrivateKey(wrapped, sessionKey);
|
||||
|
||||
console.log('[KeyPersistence] Key restored successfully');
|
||||
return privateKeyBytes;
|
||||
} catch (error) {
|
||||
console.error('[KeyPersistence] Failed to restore key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the persisted key (logout)
|
||||
*/
|
||||
export async function clearPersistentKey(): Promise<void> {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY_ITEM);
|
||||
await removeFromDB(WRAPPED_KEY_ITEM);
|
||||
console.log('[KeyPersistence] Key cleared');
|
||||
} catch (error) {
|
||||
console.error('[KeyPersistence] Error clearing key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a persisted key is available
|
||||
*/
|
||||
export async function hasPersistentKey(): Promise<boolean> {
|
||||
const sessionData = localStorage.getItem(SESSION_KEY_ITEM);
|
||||
if (!sessionData) return false;
|
||||
|
||||
try {
|
||||
const parsed: SessionData = JSON.parse(sessionData);
|
||||
const MAX_AGE = 24 * 60 * 60 * 1000;
|
||||
return Date.now() - parsed.createdAt <= MAX_AGE;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
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 as ArrayBuffer;
|
||||
}
|
||||
Reference in New Issue
Block a user