mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-06 17:37:51 +08:00
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
/**
|
|
* @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 sessionKeyId: string | 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';
|
|
keyId: string; // The ID of the key used for encryption
|
|
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<{ key: CryptoKey; keyId: string }> {
|
|
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;
|
|
sessionKeyId = null;
|
|
}
|
|
|
|
// ✅ FIXED: Use global `crypto` instead of `window.crypto` for Node.js/Bun compatibility
|
|
const key = await crypto.subtle.generateKey(
|
|
{
|
|
name: KEY_ALGORITHM,
|
|
length: KEY_LENGTH,
|
|
},
|
|
false, // non-exportable
|
|
['encrypt', 'decrypt'],
|
|
);
|
|
sessionKey = key;
|
|
sessionKeyId = crypto.randomUUID();
|
|
keyCreatedAt = now;
|
|
keyOperationCount = 0;
|
|
}
|
|
|
|
return { key: sessionKey!, keyId: sessionKeyId! };
|
|
}
|
|
|
|
/**
|
|
* 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, keyId } = 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.');
|
|
}
|
|
|
|
// ✅ FIXED: Use global `crypto` instead of `window.crypto`
|
|
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV is recommended for AES-GCM
|
|
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
|
|
// ✅ FIXED: Use global `crypto` instead of `window.crypto`
|
|
const ciphertext = await crypto.subtle.encrypt(
|
|
{
|
|
name: KEY_ALGORITHM,
|
|
iv: new Uint8Array(iv),
|
|
},
|
|
key,
|
|
plaintext,
|
|
);
|
|
|
|
return {
|
|
v: 1,
|
|
alg: 'A256GCM',
|
|
keyId: keyId,
|
|
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, keyId } = 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.');
|
|
}
|
|
if (blob.keyId !== keyId) {
|
|
throw new Error('Session expired. The encryption key has rotated. Please re-enter your seed phrase.');
|
|
}
|
|
|
|
const iv = base64ToBytes(blob.iv_b64);
|
|
const ciphertext = base64ToBytes(blob.ct_b64);
|
|
|
|
// ✅ FIXED: Use global `crypto` instead of `window.crypto`
|
|
const decrypted = await 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;
|
|
sessionKeyId = 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;
|
|
}
|