fix: Correct sessionCrypto import paths and restore missing module

- Fix App.tsx import from '../.Ref/sessionCrypto' to './lib/sessionCrypto'
- Fix main.tsx import from '../.Ref/sessionCrypto' to './lib/sessionCrypto'
- Restore src/lib/sessionCrypto.ts module (full AES-GCM encryption implementation)
- Fixes TypeScript compilation errors blocking Cloudflare Pages deployment
This commit is contained in:
LC mac
2026-02-12 23:07:44 +08:00
parent ae0c32fe67
commit 7cec260ad1
3 changed files with 338 additions and 2 deletions

View File

@@ -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';

336
src/lib/sessionCrypto.ts Normal file
View File

@@ -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<CryptoKey> {
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<T>(data: T): Promise<EncryptedBlob> {
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<T>(blob: EncryptedBlob): Promise<T> {
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<T> {
/**
* 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<T>;
/**
* Encrypts a new value and updates the internal blob.
*/
update(value: T): Promise<void>;
/**
* 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<T>(
initialValue: T
): Promise<EncryptedStateContainer<T>> {
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<T> {
if (!blob) {
throw new Error('Encrypted state is empty or has been cleared');
}
return await decryptBlobToJson<T>(blob);
},
async update(value: T): Promise<void> {
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<T>(
container: EncryptedStateContainer<T>,
transform: (current: T) => T | Promise<T>
): Promise<void> {
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<void> {
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;
}

View File

@@ -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(