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:
53
AGENTS.md
Normal file
53
AGENTS.md
Normal 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)
|
||||||
126
src/App.tsx
126
src/App.tsx
@@ -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,10 +22,12 @@ 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);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
|
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
|
||||||
const [mnemonic, setMnemonic] = useState('');
|
const [mnemonic, setMnemonic] = useState('');
|
||||||
const [backupMessagePassword, setBackupMessagePassword] = useState('');
|
const [backupMessagePassword, setBackupMessagePassword] = useState('');
|
||||||
@@ -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----- Paste or drag & drop your public key..."
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- 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----- Paste or drag & drop your private key..."
|
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- 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,13 +528,17 @@ 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 />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
39
src/components/ReadOnly.tsx
Normal file
39
src/components/ReadOnly.tsx
Normal 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
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;
|
||||||
|
}
|
||||||
@@ -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
2
src/vite-env.d.ts
vendored
@@ -6,3 +6,5 @@ declare module '*.css' {
|
|||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __BUILD_HASH__: string;
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user