diff --git a/src/App.tsx b/src/App.tsx index 0e3559c..278d4ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ import { validateBip39Mnemonic } from './lib/bip39'; import { buildPlaintext, encryptToSeed, decryptFromSeed, detectEncryptionMode, validatePGPKey } from './lib/seedpgp'; import { encodeStandardSeedQR, encodeCompactSeedQREntropy } from './lib/seedqr'; import { SecurityWarnings } from './components/SecurityWarnings'; -import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from '../.Ref/sessionCrypto'; +import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto'; import { EncryptionMode, EncryptionResult } from './lib/types'; // Import EncryptionMode and EncryptionResult import Header from './components/Header'; import { StorageDetails } from './components/StorageDetails'; diff --git a/src/lib/sessionCrypto.ts b/src/lib/sessionCrypto.ts new file mode 100644 index 0000000..5e1ff47 --- /dev/null +++ b/src/lib/sessionCrypto.ts @@ -0,0 +1,336 @@ +/** + * @file Ephemeral, per-session, in-memory encryption using Web Crypto API. + * + * This module manages a single, non-exportable AES-GCM key for a user's session. + * It's designed to encrypt sensitive data (like a mnemonic) before it's placed + * into React state, mitigating the risk of plaintext data in memory snapshots. + * The key is destroyed when the user navigates away or the session ends. + */ + +// --- Helper functions for encoding --- + +function base64ToBytes(base64: string): Uint8Array { + const binString = atob(base64); + return Uint8Array.from(binString, (m) => m.codePointAt(0)!); +} + +function bytesToBase64(bytes: Uint8Array): string { + const binString = Array.from(bytes, (byte) => + String.fromCodePoint(byte), + ).join(""); + return btoa(binString); +} + +// --- Module-level state --- + +/** + * Holds the session's AES-GCM key. This variable is not exported and is + * only accessible through the functions in this module. + * @private + */ +let sessionKey: CryptoKey | null = null; +let keyCreatedAt = 0; +let keyOperationCount = 0; +const KEY_ALGORITHM = 'AES-GCM'; +const KEY_LENGTH = 256; +const KEY_ROTATION_INTERVAL = 5 * 60 * 1000; // 5 minutes +const MAX_KEY_OPERATIONS = 1000; // Rotate after N operations + +/** + * An object containing encrypted data and necessary metadata for decryption. + */ +export interface EncryptedBlob { + v: 1; + /** + * The algorithm used. This is metadata; the actual Web Crypto API call + * uses `{ name: "AES-GCM", length: 256 }`. + */ + alg: 'A256GCM'; + iv_b64: string; // Initialization Vector (base64) + ct_b64: string; // Ciphertext (base64) +} + +// --- Core API Functions --- + +/** + * Get or create session key with automatic rotation. + * Key rotates every 5 minutes or after 1000 operations. + */ +export async function getSessionKey(): Promise { + const now = Date.now(); + const shouldRotate = + !sessionKey || + (now - keyCreatedAt) > KEY_ROTATION_INTERVAL || + keyOperationCount > MAX_KEY_OPERATIONS; + + if (shouldRotate) { + if (sessionKey) { + // Note: CryptoKey cannot be explicitly zeroed, but dereferencing helps GC + const elapsed = now - keyCreatedAt; + console.debug?.(`Rotating session key (age: ${elapsed}ms, ops: ${keyOperationCount})`); + sessionKey = null; + } + + const key = await window.crypto.subtle.generateKey( + { + name: KEY_ALGORITHM, + length: KEY_LENGTH, + }, + false, // non-exportable + ['encrypt', 'decrypt'], + ); + sessionKey = key; + keyCreatedAt = now; + keyOperationCount = 0; + } + + return sessionKey!; +} + +/** + * Encrypts a JSON-serializable object using the current session key. + * @param data The object to encrypt. Must be JSON-serializable. + * @returns A promise that resolves to an EncryptedBlob. + */ +export async function encryptJsonToBlob(data: T): Promise { + const key = await getSessionKey(); // Ensures key exists and handles rotation + keyOperationCount++; // Track operations for rotation + + if (!key) { + throw new Error('Session key not initialized. Call getSessionKey() first.'); + } + + const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV is recommended for AES-GCM + const plaintext = new TextEncoder().encode(JSON.stringify(data)); + + const ciphertext = await window.crypto.subtle.encrypt( + { + name: KEY_ALGORITHM, + iv: new Uint8Array(iv), + }, + key, + plaintext, + ); + + return { + v: 1, + alg: 'A256GCM', + iv_b64: bytesToBase64(iv), + ct_b64: bytesToBase64(new Uint8Array(ciphertext)), + }; +} + +/** + * Decrypts an EncryptedBlob back into its original object form. + * @param blob The EncryptedBlob to decrypt. + * @returns A promise that resolves to the original decrypted object. + */ +export async function decryptBlobToJson(blob: EncryptedBlob): Promise { + const key = await getSessionKey(); // Ensures key exists and handles rotation + keyOperationCount++; // Track operations for rotation + + if (!key) { + throw new Error('Session key not initialized or has been destroyed.'); + } + if (blob.v !== 1 || blob.alg !== 'A256GCM') { + throw new Error('Invalid or unsupported encrypted blob format.'); + } + + const iv = base64ToBytes(blob.iv_b64); + const ciphertext = base64ToBytes(blob.ct_b64); + + const decrypted = await window.crypto.subtle.decrypt( + { + name: KEY_ALGORITHM, + iv: new Uint8Array(iv), + }, + key, + new Uint8Array(ciphertext), + ); + + const jsonString = new TextDecoder().decode(decrypted); + return JSON.parse(jsonString) as T; +} + +/** + * Destroys the session key reference, making it unavailable for future + * operations and allowing it to be garbage collected. + */ +export function destroySessionKey(): void { + sessionKey = null; + keyOperationCount = 0; + keyCreatedAt = 0; +} + +// Auto-clear session key when page becomes hidden +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + console.debug?.('Page hidden - clearing session key for security'); + destroySessionKey(); + } + }); +} + +// --- Encrypted State Utilities --- + +/** + * Represents an encrypted state value with decryption capability. + * Used internally by useEncryptedState hook. + */ +export interface EncryptedStateContainer { + /** + * The encrypted blob containing the value and all necessary metadata. + */ + blob: EncryptedBlob | null; + + /** + * Decrypts and returns the current value. + * Throws if key is not available. + */ + decrypt(): Promise; + + /** + * Encrypts a new value and updates the internal blob. + */ + update(value: T): Promise; + + /** + * Clears the encrypted blob from memory. + * The value becomes inaccessible until update() is called again. + */ + clear(): void; +} + +/** + * Creates an encrypted state container for storing a value. + * The value is always stored encrypted and can only be accessed + * by calling decrypt(). + * + * @param initialValue The initial value to encrypt + * @returns An EncryptedStateContainer that manages encryption/decryption + * + * @example + * const container = await createEncryptedState({ seed: 'secret' }); + * const value = await container.decrypt(); // { seed: 'secret' } + * await container.update({ seed: 'new-secret' }); + * container.clear(); // Remove from memory + */ +export async function createEncryptedState( + initialValue: T +): Promise> { + let blob: EncryptedBlob | null = null; + + // Encrypt the initial value + if (initialValue !== null && initialValue !== undefined) { + blob = await encryptJsonToBlob(initialValue); + } + + return { + get blob() { + return blob; + }, + + async decrypt(): Promise { + if (!blob) { + throw new Error('Encrypted state is empty or has been cleared'); + } + return await decryptBlobToJson(blob); + }, + + async update(value: T): Promise { + blob = await encryptJsonToBlob(value); + }, + + clear(): void { + blob = null; + }, + }; +} + +/** + * Utility to safely update encrypted state with a transformation function. + * This decrypts the current value, applies a transformation, and re-encrypts. + * + * @param container The encrypted state container + * @param transform Function that receives current value and returns new value + * + * @example + * await updateEncryptedState(container, (current) => ({ + * ...current, + * updated: true + * })); + */ +export async function updateEncryptedState( + container: EncryptedStateContainer, + transform: (current: T) => T | Promise +): Promise { + const current = await container.decrypt(); + const updated = await Promise.resolve(transform(current)); + await container.update(updated); +} + +/** + * A standalone test function that can be run in the browser console + * to verify the complete encryption and decryption lifecycle. + * + * To use: + * 1. Copy this entire function into the browser's developer console. + * 2. Run it by typing: `await runSessionCryptoTest()` + * 3. Check the console for logs. + */ +export async function runSessionCryptoTest(): Promise { + console.log('--- Running Session Crypto Test ---'); + try { + // 1. Destroy any old key + destroySessionKey(); + console.log('Old key destroyed (if any).'); + + // 2. Generate a new key + await getSessionKey(); + console.log('New session key generated.'); + + // 3. Define a secret object + const originalObject = { + mnemonic: 'fee table visa input phrase lake buffalo vague merit million mesh blend', + timestamp: new Date().toISOString(), + }; + console.log('Original object:', originalObject); + + // 4. Encrypt the object + const encrypted = await encryptJsonToBlob(originalObject); + console.log('Encrypted blob:', encrypted); + if (typeof encrypted.ct_b64 !== 'string' || encrypted.ct_b64.length < 20) { + throw new Error('Encryption failed: ciphertext looks invalid.'); + } + + // 5. Decrypt the object + const decrypted = await decryptBlobToJson(encrypted); + console.log('Decrypted object:', decrypted); + + // 6. Verify integrity + if (JSON.stringify(originalObject) !== JSON.stringify(decrypted)) { + throw new Error('Verification failed: Decrypted data does not match original data.'); + } + console.log('%c✅ Success: Data integrity verified.', 'color: green; font-weight: bold;'); + + // 7. Test key destruction + destroySessionKey(); + console.log('Session key destroyed.'); + try { + await decryptBlobToJson(encrypted); + } catch (e) { + console.log('As expected, decryption failed after key destruction:', (e as Error).message); + } + } catch (error) { + console.error('%c❌ Test Failed:', 'color: red; font-weight: bold;', error); + } finally { + console.log('--- Test Complete ---'); + } +} + +// For convenience, attach the test runner to the window object. +// This is for development/testing only and can be removed in production. +if (import.meta.env.DEV && typeof window !== 'undefined') { + (window as any).runSessionCryptoTest = runSessionCryptoTest; +} diff --git a/src/main.tsx b/src/main.tsx index b106517..a3f88d3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -40,7 +40,7 @@ import './index.css' import App from './App' if (import.meta.env.DEV) { - await import('../.Ref/sessionCrypto'); + await import('./lib/sessionCrypto'); } createRoot(document.getElementById('root')!).render(