mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
feat(v1.3.0): add ephemeral session-key encryption for sensitive state
This commit is contained in:
205
src/lib/sessionCrypto.ts
Normal file
205
src/lib/sessionCrypto.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @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;
|
||||
const KEY_ALGORITHM = 'AES-GCM';
|
||||
const KEY_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* 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 ---
|
||||
|
||||
/**
|
||||
* Generates and stores a session-level AES-GCM 256-bit key.
|
||||
* The key is non-exportable and is held in a private module-level variable.
|
||||
* If a key already exists, the existing key is returned, making the function idempotent.
|
||||
* This function must be called before any encryption or decryption can occur.
|
||||
* @returns A promise that resolves to the generated or existing CryptoKey.
|
||||
*/
|
||||
export async function getSessionKey(): Promise<CryptoKey> {
|
||||
if (sessionKey) {
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: KEY_ALGORITHM,
|
||||
length: KEY_LENGTH,
|
||||
},
|
||||
false, // non-exportable
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
sessionKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
if (!sessionKey) {
|
||||
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: iv,
|
||||
},
|
||||
sessionKey,
|
||||
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> {
|
||||
if (!sessionKey) {
|
||||
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: iv,
|
||||
},
|
||||
sessionKey,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user