feat(v1.3.0): add ephemeral session-key encryption for sensitive state

This commit is contained in:
LC mac
2026-01-29 23:14:42 +08:00
parent 5a4018dcfe
commit 8e656749fe
8 changed files with 856 additions and 442 deletions

53
AGENTS.md Normal file
View File

@@ -0,0 +1,53 @@
# SeedPGP Agent Brief (read first)
## What this repo is
SeedPGP: a client-side BIP39 mnemonic encryption web app.
Goal: add features without changing security assumptions or breaking GH Pages deploy.
## Non-negotiables
- Small diffs only: one feature slice per PR (1-5 files if possible).
- No big code dumps; propose plan first, then implement.
- Never persist secrets (mnemonic, passphrases, private keys) to localStorage/sessionStorage.
- Prefer “explain what you found in the repo” over guessing.
## How to run
- Install deps: `bun install`
- Dev: `bun run dev`
- Build: `bun run build`
- Tests/lint (if present): `bun run test`, `bun run lint`, `bun run typecheck`
## Repo map (confirm/update)
- UI entry: `src/main.tsx`
- Components: `src/components/`
- Core logic/types: `src/lib/`
## Deploy
There is a deploy script (see `scripts/deploy.sh`) and a separate public repo for built output.
## Required workflow for every task
1) Repo study: identify entry points + relevant modules, list files to touch.
2) Plan: smallest vertical slice, with acceptance criteria.
3) Implement: code + minimal tests or manual verification steps.
4) Evidence: paste command output (build/test) and note any tradeoffs.
## Security Architecture (v1.3.0+)
- **Session-key encryption**: Ephemeral AES-GCM-256 key (non-exportable) encrypts sensitive state
- **Auto-clear**: Plaintext mnemonic cleared from UI immediately after QR generation
- **Encrypted cache**: Only ciphertext stored in React state; key lives in memory only
- **Lock/Clear**: Manual cleanup destroys session key + clears all state
- **Lifecycle**: Session key auto-destroyed on page close/refresh
## Module: src/lib/sessionCrypto.ts
- `getSessionKey()` - Generates/returns non-exportable AES-GCM key (idempotent)
- `encryptJsonToBlob(obj)` - Encrypts to {v, alg, iv_b64, ct_b64}
- `decryptBlobToJson(blob)` - Decrypts back to original object
- `destroySessionKey()` - Drops key reference for GC
- Test: `await window.runSessionCryptoTest()` (DEV only)

View File

@@ -1,16 +1,16 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Shield, Shield,
QrCode, QrCode,
RefreshCw, RefreshCw,
CheckCircle2, CheckCircle2, Lock,
AlertCircle, AlertCircle,
Lock,
Unlock, Unlock,
Eye, Eye,
EyeOff, EyeOff,
FileKey, FileKey,
Info Info,
WifiOff
} from 'lucide-react'; } from 'lucide-react';
import { PgpKeyInput } from './components/PgpKeyInput'; import { PgpKeyInput } from './components/PgpKeyInput';
import { QrDisplay } from './components/QrDisplay'; import { QrDisplay } from './components/QrDisplay';
@@ -22,6 +22,8 @@ import * as openpgp from 'openpgp';
import { StorageIndicator } from './components/StorageIndicator'; import { StorageIndicator } from './components/StorageIndicator';
import { SecurityWarnings } from './components/SecurityWarnings'; import { SecurityWarnings } from './components/SecurityWarnings';
import { ClipboardTracker } from './components/ClipboardTracker'; import { ClipboardTracker } from './components/ClipboardTracker';
import { ReadOnly } from './components/ReadOnly';
import { getSessionKey, encryptJsonToBlob, decryptBlobToJson, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
console.log("OpenPGP.js version:", openpgp.config.versionString); console.log("OpenPGP.js version:", openpgp.config.versionString);
@@ -44,8 +46,38 @@ function App() {
const [showMnemonic, setShowMnemonic] = useState(false); const [showMnemonic, setShowMnemonic] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showQRScanner, setShowQRScanner] = useState(false); const [showQRScanner, setShowQRScanner] = useState(false);
const [isReadOnly, setIsReadOnly] = useState(false);
const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null);
useEffect(() => {
// When entering read-only mode, clear sensitive data for security.
if (isReadOnly) {
setMnemonic('');
setBackupMessagePassword('');
setRestoreMessagePassword('');
setPublicKeyInput('');
setPrivateKeyInput('');
setPrivateKeyPassphrase('');
setQrPayload('');
setRestoreInput('');
setRestoredData(null);
setError('');
}
}, [isReadOnly]);
// Cleanup session key on component unmount
useEffect(() => {
return () => {
destroySessionKey();
};
}, []);
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
if (isReadOnly) {
setError("Copy to clipboard is disabled in Read-only mode.");
return;
}
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
@@ -82,13 +114,18 @@ function App() {
const result = await encryptToSeedPgp({ const result = await encryptToSeedPgp({
plaintext, plaintext,
publicKeyArmored: publicKeyInput || undefined, publicKeyArmored: publicKeyInput || undefined,
messagePassword: backupMessagePassword || undefined, // Changed messagePassword: backupMessagePassword || undefined,
}); });
setQrPayload(result.framed); setQrPayload(result.framed);
if (result.recipientFingerprint) { if (result.recipientFingerprint) {
setRecipientFpr(result.recipientFingerprint); setRecipientFpr(result.recipientFingerprint);
} }
// Encrypt mnemonic with session key and clear plaintext state
const blob = await encryptJsonToBlob({ mnemonic, timestamp: Date.now() });
setEncryptedMnemonicCache(blob);
setMnemonic(''); // Clear plaintext mnemonic
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Encryption failed'); setError(e instanceof Error ? e.message : 'Encryption failed');
} finally { } finally {
@@ -106,7 +143,7 @@ function App() {
frameText: restoreInput, frameText: restoreInput,
privateKeyArmored: privateKeyInput || undefined, privateKeyArmored: privateKeyInput || undefined,
privateKeyPassphrase: privateKeyPassphrase || undefined, privateKeyPassphrase: privateKeyPassphrase || undefined,
messagePassword: restoreMessagePassword || undefined, // Changed messagePassword: restoreMessagePassword || undefined,
}); });
@@ -118,6 +155,25 @@ function App() {
} }
}; };
const handleLockAndClear = () => {
destroySessionKey();
setEncryptedMnemonicCache(null);
setMnemonic('');
setBackupMessagePassword('');
setRestoreMessagePassword('');
setPublicKeyInput('');
setPrivateKeyInput('');
setPrivateKeyPassphrase('');
setQrPayload('');
setRecipientFpr('');
setRestoreInput('');
setRestoredData(null);
setError('');
setShowMnemonic(false);
setCopied(false);
setShowQRScanner(false);
};
return ( return (
<> <>
@@ -132,11 +188,27 @@ function App() {
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.2</span> SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v{__APP_VERSION__}</span>
</h1> </h1>
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p> <p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
</div> </div>
</div> </div>
{encryptedMnemonicCache && ( // Show only if encrypted data exists
<button
onClick={handleLockAndClear}
className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50 transition-colors"
>
<Lock size={16} />
<span>Lock/Clear</span>
</button>
)}
<div className="flex items-center gap-4">
{isReadOnly && (
<div className="flex items-center gap-2 text-sm text-amber-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
<WifiOff size={16} />
<span>Read-only</span>
</div>
)}
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur"> <div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
<button <button
onClick={() => { onClick={() => {
@@ -168,6 +240,7 @@ function App() {
</button> </button>
</div> </div>
</div> </div>
</div>
<div className="p-6 md:p-8 space-y-6"> <div className="p-6 md:p-8 space-y-6">
{/* Error Display */} {/* Error Display */}
@@ -204,6 +277,7 @@ function App() {
placeholder="Enter your 12 or 24 word seed phrase..." placeholder="Enter your 12 or 24 word seed phrase..."
value={mnemonic} value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)} onChange={(e) => setMnemonic(e.target.value)}
readOnly={isReadOnly}
/> />
</div> </div>
@@ -213,6 +287,7 @@ function App() {
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..." placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..."
value={publicKeyInput} value={publicKeyInput}
onChange={setPublicKeyInput} onChange={setPublicKeyInput}
readOnly={isReadOnly}
/> />
</> </>
) : ( ) : (
@@ -220,7 +295,8 @@ function App() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setShowQRScanner(true)} onClick={() => setShowQRScanner(true)}
className="flex-1 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-purple-700 hover:to-purple-800 transition-all shadow-lg" disabled={isReadOnly}
className="flex-1 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-purple-700 hover:to-purple-800 transition-all shadow-lg disabled:opacity-50"
> >
<QrCode size={18} /> <QrCode size={18} />
Scan QR Code Scan QR Code
@@ -234,6 +310,7 @@ function App() {
placeholder="SEEDPGP1:0:ABCD:..." placeholder="SEEDPGP1:0:ABCD:..."
value={restoreInput} value={restoreInput}
onChange={(e) => setRestoreInput(e.target.value)} onChange={(e) => setRestoreInput(e.target.value)}
readOnly={isReadOnly}
/> />
</div> </div>
@@ -244,6 +321,7 @@ function App() {
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..." placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..."
value={privateKeyInput} value={privateKeyInput}
onChange={setPrivateKeyInput} onChange={setPrivateKeyInput}
readOnly={isReadOnly}
/> />
{privateKeyInput && ( {privateKeyInput && (
@@ -258,6 +336,7 @@ function App() {
placeholder="Unlock private key..." placeholder="Unlock private key..."
value={privateKeyPassphrase} value={privateKeyPassphrase}
onChange={(e) => setPrivateKeyPassphrase(e.target.value)} onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
readOnly={isReadOnly}
/> />
</div> </div>
</div> </div>
@@ -283,6 +362,7 @@ function App() {
placeholder="Optional password..." placeholder="Optional password..."
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword} value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)} onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
readOnly={isReadOnly}
/> />
</div> </div>
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p> <p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
@@ -296,6 +376,7 @@ function App() {
type="checkbox" type="checkbox"
checked={hasBip39Passphrase} checked={hasBip39Passphrase}
onChange={(e) => setHasBip39Passphrase(e.target.checked)} onChange={(e) => setHasBip39Passphrase(e.target.checked)}
disabled={isReadOnly}
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all" className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
/> />
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors"> <span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
@@ -304,13 +385,20 @@ function App() {
</label> </label>
</div> </div>
)} )}
<ReadOnly
isReadOnly={isReadOnly}
onToggle={setIsReadOnly}
appVersion={__APP_VERSION__}
buildHash={__BUILD_HASH__}
/>
</div> </div>
{/* Action Button */} {/* Action Button */}
{activeTab === 'backup' ? ( {activeTab === 'backup' ? (
<button <button
onClick={handleBackup} onClick={handleBackup}
disabled={!mnemonic || loading} disabled={!mnemonic || loading || isReadOnly}
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-blue-600 disabled:hover:to-blue-700" className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-blue-600 disabled:hover:to-blue-700"
> >
{loading ? ( {loading ? (
@@ -323,7 +411,7 @@ function App() {
) : ( ) : (
<button <button
onClick={handleRestore} onClick={handleRestore}
disabled={!restoreInput || loading} disabled={!restoreInput || loading || isReadOnly}
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed" className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
@@ -418,7 +506,7 @@ function App() {
{/* Footer */} {/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500"> <div className="mt-8 text-center text-xs text-slate-500">
<p>SeedPGP v1.2 OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p> <p>SeedPGP v{__APP_VERSION__} OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p>
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p> <p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
</div> </div>
</div> </div>
@@ -440,9 +528,13 @@ function App() {
</div> </div>
{/* Floating Storage Monitor - bottom right */} {/* Floating Storage Monitor - bottom right */}
{!isReadOnly && (
<>
<StorageIndicator /> <StorageIndicator />
<SecurityWarnings /> {/* Bottom-left */} <SecurityWarnings />
<ClipboardTracker /> {/* Top-right */} <ClipboardTracker />
</>
)}
</> </>
); );

View File

@@ -1,15 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Upload } from 'lucide-react'; import { Upload } from 'lucide-react';
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
interface PgpKeyInputProps { interface PgpKeyInputProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder: string; placeholder: string;
label: string; label: string;
icon?: LucideIcon; icon?: LucideIcon;
readOnly?: boolean;
} }
export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
@@ -17,21 +16,25 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
onChange, onChange,
placeholder, placeholder,
label, label,
icon: Icon icon: Icon,
readOnly = false,
}) => { }) => {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault(); e.preventDefault();
setIsDragging(true); setIsDragging(true);
}; };
const handleDragLeave = (e: React.DragEvent) => { const handleDragLeave = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragging(false);
}; };
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragging(false);
@@ -53,24 +56,27 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{Icon && <Icon size={14} />} {label} {Icon && <Icon size={14} />} {label}
</span> </span>
{!readOnly && (
<span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200"> <span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
Drag & Drop .asc file Drag & Drop .asc file
</span> </span>
)}
</label> </label>
<div <div
className={`relative transition-all duration-200 ${isDragging ? 'scale-[1.01]' : ''}`} className={`relative transition-all duration-200 ${isDragging && !readOnly ? 'scale-[1.01]' : ''}`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
> >
<textarea <textarea
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 ${isDragging ? 'border-blue-500 bg-blue-50' : 'border-slate-200' className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 ${isDragging && !readOnly ? 'border-blue-500 bg-blue-50' : 'border-slate-200'
}`} }`}
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
readOnly={readOnly}
/> />
{isDragging && ( {isDragging && !readOnly && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-50/90 rounded-xl border-2 border-dashed border-blue-500 pointer-events-none z-10"> <div className="absolute inset-0 flex items-center justify-center bg-blue-50/90 rounded-xl border-2 border-dashed border-blue-500 pointer-events-none z-10">
<div className="text-blue-600 font-bold flex flex-col items-center animate-bounce"> <div className="text-blue-600 font-bold flex flex-col items-center animate-bounce">
<Upload size={24} /> <Upload size={24} />

View File

@@ -0,0 +1,39 @@
import { Shield, WifiOff } from 'lucide-react';
type ReadOnlyProps = {
isReadOnly: boolean;
onToggle: (isReadOnly: boolean) => void;
buildHash: string;
appVersion: string;
};
const CSP_POLICY = `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none';`;
export function ReadOnly({ isReadOnly, onToggle, buildHash, appVersion }: ReadOnlyProps) {
return (
<div className="pt-3 border-t border-slate-300">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={isReadOnly}
onChange={(e) => onToggle(e.target.checked)}
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
/>
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
Read-only Mode
</span>
</label>
{isReadOnly && (
<div className="mt-4 p-3 bg-slate-800 text-slate-200 rounded-lg text-xs space-y-2 animate-in fade-in">
<p className="font-bold flex items-center gap-2"><WifiOff size={14} /> Network & Persistence Disabled</p>
<div className="font-mono text-[10px] space-y-1">
<p><span className="font-semibold text-slate-400">Version:</span> {appVersion}</p>
<p><span className="font-semibold text-slate-400">Build:</span> {buildHash}</p>
<p className="pt-1 font-semibold text-slate-400">Content Security Policy:</p>
<p className="text-sky-300 break-words">{CSP_POLICY}</p>
</div>
</div>
)}
</div>
);
}

205
src/lib/sessionCrypto.ts Normal file
View 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;
}

View File

@@ -23,6 +23,10 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App' import App from './App'
if (import.meta.env.DEV) {
await import('./lib/sessionCrypto');
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />

2
src/vite-env.d.ts vendored
View File

@@ -6,3 +6,5 @@ declare module '*.css' {
export default content; export default content;
} }
declare const __APP_VERSION__: string;
declare const __BUILD_HASH__: string;

View File

@@ -1,5 +1,14 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
import fs from 'fs'
// Read version from package.json
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
const appVersion = packageJson.version
// Get git commit hash
const gitHash = execSync('git rev-parse --short HEAD').toString().trim()
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
@@ -7,5 +16,9 @@ export default defineConfig({
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: false, emptyOutDir: false,
},
define: {
'__APP_VERSION__': JSON.stringify(appVersion),
'__BUILD_HASH__': JSON.stringify(gitHash),
} }
}) })