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 {
Shield,
QrCode,
RefreshCw,
CheckCircle2,
CheckCircle2, Lock,
AlertCircle,
Lock,
Unlock,
Eye,
EyeOff,
FileKey,
Info
Info,
WifiOff
} from 'lucide-react';
import { PgpKeyInput } from './components/PgpKeyInput';
import { QrDisplay } from './components/QrDisplay';
@@ -22,431 +22,523 @@ import * as openpgp from 'openpgp';
import { StorageIndicator } from './components/StorageIndicator';
import { SecurityWarnings } from './components/SecurityWarnings';
import { ClipboardTracker } from './components/ClipboardTracker';
console.log("OpenPGP.js version:", openpgp.config.versionString);
function App() {
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
const [mnemonic, setMnemonic] = useState('');
const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
const [publicKeyInput, setPublicKeyInput] = useState('');
const [privateKeyInput, setPrivateKeyInput] = useState('');
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
const [qrPayload, setQrPayload] = useState('');
const [recipientFpr, setRecipientFpr] = useState('');
const [restoreInput, setRestoreInput] = useState('');
const [restoredData, setRestoredData] = useState<SeedPgpPlaintext | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showMnemonic, setShowMnemonic] = useState(false);
const [copied, setCopied] = useState(false);
const [showQRScanner, setShowQRScanner] = useState(false);
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
};
const handleBackup = async () => {
setLoading(true);
setError('');
setQrPayload('');
setRecipientFpr('');
try {
const validation = validateBip39Mnemonic(mnemonic);
if (!validation.valid) {
throw new Error(validation.error);
}
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase);
const result = await encryptToSeedPgp({
plaintext,
publicKeyArmored: publicKeyInput || undefined,
messagePassword: backupMessagePassword || undefined, // Changed
});
setQrPayload(result.framed);
if (result.recipientFingerprint) {
setRecipientFpr(result.recipientFingerprint);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Encryption failed');
} finally {
setLoading(false);
}
};
const handleRestore = async () => {
setLoading(true);
setError('');
setRestoredData(null);
try {
const result = await decryptSeedPgp({
frameText: restoreInput,
privateKeyArmored: privateKeyInput || undefined,
privateKeyPassphrase: privateKeyPassphrase || undefined,
messagePassword: restoreMessagePassword || undefined, // Changed
});
setRestoredData(result);
} catch (e) {
setError(e instanceof Error ? e.message : 'Decryption failed');
} finally {
setLoading(false);
}
};
return (
<>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
{/* Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-600 rounded-lg shadow-lg">
<Shield size={28} />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.2</span>
</h1>
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
</div>
</div>
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
<button
onClick={() => {
setActiveTab('backup');
setError('');
setQrPayload('');
setRestoredData(null);
}}
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
? 'bg-white text-slate-900 shadow-lg'
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
}`}
>
Backup
</button>
<button
onClick={() => {
setActiveTab('restore');
setError('');
setQrPayload('');
setRestoredData(null);
}}
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
? 'bg-white text-slate-900 shadow-lg'
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
}`}
>
Restore
</button>
</div>
</div>
<div className="p-6 md:p-8 space-y-6">
{/* Error Display */}
{error && (
<div className="p-4 bg-red-50 border-l-4 border-red-500 rounded-r-xl flex gap-3 text-red-800 text-sm items-start animate-in slide-in-from-top-2">
<AlertCircle className="shrink-0 mt-0.5" size={20} />
<div>
<p className="font-bold mb-1">Error</p>
<p className="whitespace-pre-wrap">{error}</p>
</div>
</div>
)}
{/* Info Banner */}
{recipientFpr && activeTab === 'backup' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3 text-blue-800 text-xs animate-in fade-in">
<Info size={16} className="shrink-0 mt-0.5" />
<div>
<strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
</div>
</div>
)}
{/* Main Content Grid */}
<div className="grid gap-6 md:grid-cols-3">
<div className="md:col-span-2 space-y-6">
{activeTab === 'backup' ? (
<>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
<textarea
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
data-sensitive="BIP39 Mnemonic"
placeholder="Enter your 12 or 24 word seed phrase..."
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
/>
</div>
<PgpKeyInput
label="PGP Public Key (Optional)"
icon={FileKey}
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..."
value={publicKeyInput}
onChange={setPublicKeyInput}
/>
</>
) : (
<>
<div className="flex gap-2">
<button
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"
>
<QrCode size={18} />
Scan QR Code
</button>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
<textarea
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
placeholder="SEEDPGP1:0:ABCD:..."
value={restoreInput}
onChange={(e) => setRestoreInput(e.target.value)}
/>
</div>
<PgpKeyInput
label="PGP Private Key (Optional)"
icon={FileKey}
data-sensitive="PGP Private Key"
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..."
value={privateKeyInput}
onChange={setPrivateKeyInput}
/>
{privateKeyInput && (
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
<div className="relative">
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input
type="password"
data-sensitive="Message Password"
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="Unlock private key..."
value={privateKeyPassphrase}
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
/>
</div>
</div>
)}
</>
)}
</div>
{/* Security Panel */}
<div className="space-y-6">
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-wider flex items-center gap-2">
<Lock size={14} /> Security Options
</h3>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
<div className="relative">
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input
type="password"
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="Optional password..."
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
</div>
{activeTab === 'backup' && (
<div className="pt-3 border-t border-slate-300">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={hasBip39Passphrase}
onChange={(e) => setHasBip39Passphrase(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">
BIP39 25th word active
</span>
</label>
</div>
)}
</div>
{/* Action Button */}
{activeTab === 'backup' ? (
<button
onClick={handleBackup}
disabled={!mnemonic || loading}
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 ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<QrCode size={20} />
)}
{loading ? 'Generating...' : 'Generate QR Backup'}
</button>
) : (
<button
onClick={handleRestore}
disabled={!restoreInput || loading}
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 ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Unlock size={20} />
)}
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
</button>
)}
</div>
</div>
{/* QR Output */}
{qrPayload && activeTab === 'backup' && (
<div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4">
<div className="flex justify-center">
<QrDisplay value={qrPayload} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">
Raw payload (copy for backup)
</label>
<button
type="button"
onClick={() => copyToClipboard(qrPayload)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-900 text-white text-xs font-semibold hover:bg-black transition-colors"
>
{copied ? <CheckCircle2 size={14} /> : <QrCode size={14} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<textarea
readOnly
value={qrPayload}
onFocus={(e) => e.currentTarget.select()}
className="w-full h-28 p-3 bg-slate-900 rounded-xl font-mono text-[10px] text-green-400 border border-slate-700 shadow-inner leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-[11px] text-slate-500">
Tip: click the box to select all, or use Copy.
</p>
</div>
</div>
)}
{/* Restored Mnemonic */}
{restoredData && activeTab === 'restore' && (
<div className="pt-6 border-t border-slate-200 animate-in zoom-in-95">
<div className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
<div className="flex items-center justify-between mb-4">
<span className="font-bold text-green-700 flex items-center gap-2 text-lg">
<CheckCircle2 size={22} /> Mnemonic Recovered
</span>
<button
onClick={() => setShowMnemonic(!showMnemonic)}
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
>
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />}
</button>
</div>
<div className={`p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm transition-all duration-300 ${showMnemonic ? 'blur-0' : 'blur-lg select-none'
}`}>
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
{restoredData.w}
</p>
</div>
{restoredData.pp === 1 && (
<div className="mt-4 p-3 bg-orange-100 border border-orange-300 rounded-lg">
<p className="text-xs text-center text-orange-800 font-bold uppercase tracking-widest flex items-center justify-center gap-2">
<AlertCircle size={14} /> BIP39 Passphrase Required (25th Word)
</p>
</div>
)}
{restoredData.fpr && restoredData.fpr.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-xs text-blue-800">
<strong>Encrypted for keys:</strong> {restoredData.fpr.join(', ')}
</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<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 className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
</div>
</div>
{/* QR Scanner Modal */}
{showQRScanner && (
<QRScanner
onScanSuccess={(scannedText) => {
setRestoreInput(scannedText);
setShowQRScanner(false);
setError('');
}}
onClose={() => setShowQRScanner(false)}
/>
)}
<div className="max-w-4xl mx-auto p-8">
<h1>SeedPGP v1.2.0</h1>
{/* ... rest of your app ... */}
</div>
{/* Floating Storage Monitor - bottom right */}
<StorageIndicator />
<SecurityWarnings /> {/* Bottom-left */}
<ClipboardTracker /> {/* Top-right */}
</>
);
}
export default App;
import { ReadOnly } from './components/ReadOnly';
import { getSessionKey, encryptJsonToBlob, decryptBlobToJson, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
console.log("OpenPGP.js version:", openpgp.config.versionString);
function App() {
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
const [mnemonic, setMnemonic] = useState('');
const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
const [publicKeyInput, setPublicKeyInput] = useState('');
const [privateKeyInput, setPrivateKeyInput] = useState('');
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
const [qrPayload, setQrPayload] = useState('');
const [recipientFpr, setRecipientFpr] = useState('');
const [restoreInput, setRestoreInput] = useState('');
const [restoredData, setRestoredData] = useState<SeedPgpPlaintext | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showMnemonic, setShowMnemonic] = useState(false);
const [copied, setCopied] = 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) => {
if (isReadOnly) {
setError("Copy to clipboard is disabled in Read-only mode.");
return;
}
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
};
const handleBackup = async () => {
setLoading(true);
setError('');
setQrPayload('');
setRecipientFpr('');
try {
const validation = validateBip39Mnemonic(mnemonic);
if (!validation.valid) {
throw new Error(validation.error);
}
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase);
const result = await encryptToSeedPgp({
plaintext,
publicKeyArmored: publicKeyInput || undefined,
messagePassword: backupMessagePassword || undefined,
});
setQrPayload(result.framed);
if (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) {
setError(e instanceof Error ? e.message : 'Encryption failed');
} finally {
setLoading(false);
}
};
const handleRestore = async () => {
setLoading(true);
setError('');
setRestoredData(null);
try {
const result = await decryptSeedPgp({
frameText: restoreInput,
privateKeyArmored: privateKeyInput || undefined,
privateKeyPassphrase: privateKeyPassphrase || undefined,
messagePassword: restoreMessagePassword || undefined,
});
setRestoredData(result);
} catch (e) {
setError(e instanceof Error ? e.message : 'Decryption failed');
} finally {
setLoading(false);
}
};
const handleLockAndClear = () => {
destroySessionKey();
setEncryptedMnemonicCache(null);
setMnemonic('');
setBackupMessagePassword('');
setRestoreMessagePassword('');
setPublicKeyInput('');
setPrivateKeyInput('');
setPrivateKeyPassphrase('');
setQrPayload('');
setRecipientFpr('');
setRestoreInput('');
setRestoredData(null);
setError('');
setShowMnemonic(false);
setCopied(false);
setShowQRScanner(false);
};
return (
<>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
{/* Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-600 rounded-lg shadow-lg">
<Shield size={28} />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v{__APP_VERSION__}</span>
</h1>
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
</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">
<button
onClick={() => {
setActiveTab('backup');
setError('');
setQrPayload('');
setRestoredData(null);
}}
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
? 'bg-white text-slate-900 shadow-lg'
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
}`}
>
Backup
</button>
<button
onClick={() => {
setActiveTab('restore');
setError('');
setQrPayload('');
setRestoredData(null);
}}
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
? 'bg-white text-slate-900 shadow-lg'
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
}`}
>
Restore
</button>
</div>
</div>
</div>
<div className="p-6 md:p-8 space-y-6">
{/* Error Display */}
{error && (
<div className="p-4 bg-red-50 border-l-4 border-red-500 rounded-r-xl flex gap-3 text-red-800 text-sm items-start animate-in slide-in-from-top-2">
<AlertCircle className="shrink-0 mt-0.5" size={20} />
<div>
<p className="font-bold mb-1">Error</p>
<p className="whitespace-pre-wrap">{error}</p>
</div>
</div>
)}
{/* Info Banner */}
{recipientFpr && activeTab === 'backup' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3 text-blue-800 text-xs animate-in fade-in">
<Info size={16} className="shrink-0 mt-0.5" />
<div>
<strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
</div>
</div>
)}
{/* Main Content Grid */}
<div className="grid gap-6 md:grid-cols-3">
<div className="md:col-span-2 space-y-6">
{activeTab === 'backup' ? (
<>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
<textarea
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
data-sensitive="BIP39 Mnemonic"
placeholder="Enter your 12 or 24 word seed phrase..."
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<PgpKeyInput
label="PGP Public Key (Optional)"
icon={FileKey}
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..."
value={publicKeyInput}
onChange={setPublicKeyInput}
readOnly={isReadOnly}
/>
</>
) : (
<>
<div className="flex gap-2">
<button
onClick={() => setShowQRScanner(true)}
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} />
Scan QR Code
</button>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
<textarea
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
placeholder="SEEDPGP1:0:ABCD:..."
value={restoreInput}
onChange={(e) => setRestoreInput(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<PgpKeyInput
label="PGP Private Key (Optional)"
icon={FileKey}
data-sensitive="PGP Private Key"
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..."
value={privateKeyInput}
onChange={setPrivateKeyInput}
readOnly={isReadOnly}
/>
{privateKeyInput && (
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
<div className="relative">
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input
type="password"
data-sensitive="Message Password"
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="Unlock private key..."
value={privateKeyPassphrase}
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
readOnly={isReadOnly}
/>
</div>
</div>
)}
</>
)}
</div>
{/* Security Panel */}
<div className="space-y-6">
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-wider flex items-center gap-2">
<Lock size={14} /> Security Options
</h3>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
<div className="relative">
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input
type="password"
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="Optional password..."
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
</div>
{activeTab === 'backup' && (
<div className="pt-3 border-t border-slate-300">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={hasBip39Passphrase}
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
disabled={isReadOnly}
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">
BIP39 25th word active
</span>
</label>
</div>
)}
<ReadOnly
isReadOnly={isReadOnly}
onToggle={setIsReadOnly}
appVersion={__APP_VERSION__}
buildHash={__BUILD_HASH__}
/>
</div>
{/* Action Button */}
{activeTab === 'backup' ? (
<button
onClick={handleBackup}
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"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<QrCode size={20} />
)}
{loading ? 'Generating...' : 'Generate QR Backup'}
</button>
) : (
<button
onClick={handleRestore}
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"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Unlock size={20} />
)}
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
</button>
)}
</div>
</div>
{/* QR Output */}
{qrPayload && activeTab === 'backup' && (
<div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4">
<div className="flex justify-center">
<QrDisplay value={qrPayload} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">
Raw payload (copy for backup)
</label>
<button
type="button"
onClick={() => copyToClipboard(qrPayload)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-900 text-white text-xs font-semibold hover:bg-black transition-colors"
>
{copied ? <CheckCircle2 size={14} /> : <QrCode size={14} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<textarea
readOnly
value={qrPayload}
onFocus={(e) => e.currentTarget.select()}
className="w-full h-28 p-3 bg-slate-900 rounded-xl font-mono text-[10px] text-green-400 border border-slate-700 shadow-inner leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-[11px] text-slate-500">
Tip: click the box to select all, or use Copy.
</p>
</div>
</div>
)}
{/* Restored Mnemonic */}
{restoredData && activeTab === 'restore' && (
<div className="pt-6 border-t border-slate-200 animate-in zoom-in-95">
<div className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
<div className="flex items-center justify-between mb-4">
<span className="font-bold text-green-700 flex items-center gap-2 text-lg">
<CheckCircle2 size={22} /> Mnemonic Recovered
</span>
<button
onClick={() => setShowMnemonic(!showMnemonic)}
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
>
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />}
</button>
</div>
<div className={`p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm transition-all duration-300 ${showMnemonic ? 'blur-0' : 'blur-lg select-none'
}`}>
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
{restoredData.w}
</p>
</div>
{restoredData.pp === 1 && (
<div className="mt-4 p-3 bg-orange-100 border border-orange-300 rounded-lg">
<p className="text-xs text-center text-orange-800 font-bold uppercase tracking-widest flex items-center justify-center gap-2">
<AlertCircle size={14} /> BIP39 Passphrase Required (25th Word)
</p>
</div>
)}
{restoredData.fpr && restoredData.fpr.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-xs text-blue-800">
<strong>Encrypted for keys:</strong> {restoredData.fpr.join(', ')}
</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500">
<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>
</div>
</div>
{/* QR Scanner Modal */}
{showQRScanner && (
<QRScanner
onScanSuccess={(scannedText) => {
setRestoreInput(scannedText);
setShowQRScanner(false);
setError('');
}}
onClose={() => setShowQRScanner(false)}
/>
)}
<div className="max-w-4xl mx-auto p-8">
<h1>SeedPGP v1.2.0</h1>
{/* ... rest of your app ... */}
</div>
{/* Floating Storage Monitor - bottom right */}
{!isReadOnly && (
<>
<StorageIndicator />
<SecurityWarnings />
<ClipboardTracker />
</>
)}
</>
);
}
export default App;

View File

@@ -1,15 +1,14 @@
import React, { useState } from 'react';
import { Upload } from 'lucide-react';
import type { LucideIcon } from "lucide-react";
interface PgpKeyInputProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
label: string;
icon?: LucideIcon;
readOnly?: boolean;
}
export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
@@ -17,21 +16,25 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
onChange,
placeholder,
label,
icon: Icon
icon: Icon,
readOnly = false,
}) => {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(false);
@@ -53,24 +56,27 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
<span className="flex items-center gap-2">
{Icon && <Icon size={14} />} {label}
</span>
<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
</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">
Drag & Drop .asc file
</span>
)}
</label>
<div
className={`relative transition-all duration-200 ${isDragging ? 'scale-[1.01]' : ''}`}
className={`relative transition-all duration-200 ${isDragging && !readOnly ? 'scale-[1.01]' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<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}
value={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="text-blue-600 font-bold flex flex-col items-center animate-bounce">
<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 App from './App'
if (import.meta.env.DEV) {
await import('./lib/sessionCrypto');
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />

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

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

View File

@@ -1,5 +1,14 @@
import { defineConfig } from 'vite'
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({
plugins: [react()],
@@ -7,5 +16,9 @@ export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: false,
},
define: {
'__APP_VERSION__': JSON.stringify(appVersion),
'__BUILD_HASH__': JSON.stringify(gitHash),
}
})