update badges, cosmetic things and UI change

This commit is contained in:
LC mac
2026-01-30 18:44:27 +08:00
parent 81fbd210ca
commit 32dff01132
12 changed files with 748 additions and 652 deletions

View File

@@ -1,35 +0,0 @@
#!/bin/bash
set -e
VERSION=$1
if [ -z "$VERSION" ]; then
echo "Usage: ./scripts/deploy.sh v1.2.0"
exit 1
fi
echo "🔨 Building $VERSION..."
# Remove old build files but keep .git
rm -rf dist/assets dist/index.html dist/*.js dist/*.css dist/vite.svg
bun run build
echo "📄 Adding README..."
if [ -f public/README.md ]; then
cp public/README.md dist/README.md
fi
echo "📦 Deploying to GitHub Pages..."
cd dist
git add .
git commit -m "Deploy $VERSION" || echo "No changes to commit"
git push
cd ..
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
echo ""
echo "Tag private repo:"
echo " git tag $VERSION && git push origin --tags"

View File

@@ -1,15 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Shield,
QrCode, QrCode,
RefreshCw, RefreshCw,
CheckCircle2, Lock, CheckCircle2,
Lock,
AlertCircle, AlertCircle,
Unlock, Unlock,
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';
@@ -17,245 +16,326 @@ import QRScanner from './components/QRScanner';
import { validateBip39Mnemonic } from './lib/bip39'; import { validateBip39Mnemonic } from './lib/bip39';
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp'; import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
import * as openpgp from 'openpgp'; import * as openpgp from 'openpgp';
import { StorageIndicator } from './components/StorageIndicator';
import { SecurityWarnings } from './components/SecurityWarnings'; import { SecurityWarnings } from './components/SecurityWarnings';
import { ClipboardTracker } from './components/ClipboardTracker';
import { ReadOnly } from './components/ReadOnly'; import { ReadOnly } from './components/ReadOnly';
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto'; import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
import Header from './components/Header';
console.log("OpenPGP.js version:", openpgp.config.versionString); import { StorageDetails } from './components/StorageDetails';
import { ClipboardDetails } from './components/ClipboardDetails';
function App() {
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup'); console.log("OpenPGP.js version:", openpgp.config.versionString);
const [mnemonic, setMnemonic] = useState('');
const [backupMessagePassword, setBackupMessagePassword] = useState(''); interface StorageItem {
const [restoreMessagePassword, setRestoreMessagePassword] = useState(''); key: string;
value: string;
const [publicKeyInput, setPublicKeyInput] = useState(''); size: number;
const [privateKeyInput, setPrivateKeyInput] = useState(''); isSensitive: boolean;
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState(''); }
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
const [qrPayload, setQrPayload] = useState(''); interface ClipboardEvent {
const [recipientFpr, setRecipientFpr] = useState(''); timestamp: Date;
const [restoreInput, setRestoreInput] = useState(''); field: string;
const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null); length: number;
const [error, setError] = useState(''); }
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false); function App() {
const [showQRScanner, setShowQRScanner] = useState(false); const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
const [isReadOnly, setIsReadOnly] = useState(false); const [mnemonic, setMnemonic] = useState('');
const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null); const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
useEffect(() => {
// When entering read-only mode, clear sensitive data for security. const [publicKeyInput, setPublicKeyInput] = useState('');
if (isReadOnly) { const [privateKeyInput, setPrivateKeyInput] = useState('');
setMnemonic(''); const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
setBackupMessagePassword(''); const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
setRestoreMessagePassword(''); const [qrPayload, setQrPayload] = useState('');
setPublicKeyInput(''); const [recipientFpr, setRecipientFpr] = useState('');
setPrivateKeyInput(''); const [restoreInput, setRestoreInput] = useState('');
setPrivateKeyPassphrase(''); const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null);
setQrPayload(''); const [error, setError] = useState('');
setRestoreInput(''); const [loading, setLoading] = useState(false);
setDecryptedRestoredMnemonic(null); const [copied, setCopied] = useState(false);
setError(''); const [showQRScanner, setShowQRScanner] = useState(false);
} const [isReadOnly, setIsReadOnly] = useState(false);
}, [isReadOnly]); const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null);
const [showSecurityModal, setShowSecurityModal] = useState(false);
// Cleanup session key on component unmount const [showStorageModal, setShowStorageModal] = useState(false);
useEffect(() => { const [showClipboardModal, setShowClipboardModal] = useState(false);
return () => { const [localItems, setLocalItems] = useState<StorageItem[]>([]);
destroySessionKey(); const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
}; const [clipboardEvents, setClipboardEvents] = useState<ClipboardEvent[]>([]);
}, []);
const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password'];
const copyToClipboard = async (text: string) => { const isSensitiveKey = (key: string): boolean => {
if (isReadOnly) { const lowerKey = key.toLowerCase();
setError("Copy to clipboard is disabled in Read-only mode."); return SENSITIVE_PATTERNS.some(pattern => lowerKey.includes(pattern));
return; };
}
try { const getStorageItems = (storage: Storage): StorageItem[] => {
await navigator.clipboard.writeText(text); const items: StorageItem[] = [];
setCopied(true); for (let i = 0; i < storage.length; i++) {
window.setTimeout(() => setCopied(false), 1500); const key = storage.key(i);
} catch { if (key) {
const ta = document.createElement("textarea"); const value = storage.getItem(key) || '';
ta.value = text; items.push({
ta.style.position = "fixed"; key,
ta.style.left = "-9999px"; value: value.substring(0, 50) + (value.length > 50 ? '...' : ''),
document.body.appendChild(ta); size: new Blob([value]).size,
ta.focus(); isSensitive: isSensitiveKey(key)
ta.select(); });
document.execCommand("copy"); }
document.body.removeChild(ta); }
setCopied(true); return items.sort((a, b) => (b.isSensitive ? 1 : 0) - (a.isSensitive ? 1 : 0));
window.setTimeout(() => setCopied(false), 1500); };
}
}; const refreshStorage = () => {
setLocalItems(getStorageItems(localStorage));
const handleBackup = async () => { setSessionItems(getStorageItems(sessionStorage));
setLoading(true); };
setError('');
setQrPayload(''); useEffect(() => {
setRecipientFpr(''); refreshStorage();
const interval = setInterval(refreshStorage, 2000);
try { return () => clearInterval(interval);
const validation = validateBip39Mnemonic(mnemonic); }, []);
if (!validation.valid) {
throw new Error(validation.error); useEffect(() => {
} // When entering read-only mode, clear sensitive data for security.
if (isReadOnly) {
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase); setMnemonic('');
setBackupMessagePassword('');
const result = await encryptToSeedPgp({ setRestoreMessagePassword('');
plaintext, setPublicKeyInput('');
publicKeyArmored: publicKeyInput || undefined, setPrivateKeyInput('');
messagePassword: backupMessagePassword || undefined, setPrivateKeyPassphrase('');
}); setQrPayload('');
setRestoreInput('');
setQrPayload(result.framed); setDecryptedRestoredMnemonic(null);
if (result.recipientFingerprint) { setError('');
setRecipientFpr(result.recipientFingerprint); }
} }, [isReadOnly]);
// Initialize session key before encrypting // Cleanup session key on component unmount
useEffect(() => {
return () => {
destroySessionKey();
};
}, []);
useEffect(() => {
const handleCopy = (e: ClipboardEvent & Event) => {
const target = e.target as HTMLElement;
// Get selection to measure length
const selection = window.getSelection()?.toString() || '';
const length = selection.length;
if (length === 0) return; // Nothing copied
// Detect field name
let field = 'Unknown field';
if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') {
// Try multiple ways to identify the field
field =
target.getAttribute('aria-label') ||
target.getAttribute('name') ||
target.getAttribute('id') ||
(target as HTMLInputElement).type ||
target.tagName.toLowerCase();
// Check parent labels
const label = target.closest('label') ||
document.querySelector(`label[for="${target.id}"]`);
if (label) {
field = label.textContent?.trim() || field;
}
// Check for data-sensitive attribute
const sensitiveAttr = target.getAttribute('data-sensitive') ||
target.closest('[data-sensitive]')?.getAttribute('data-sensitive');
if (sensitiveAttr) {
field = sensitiveAttr;
}
// Detect if it looks like sensitive data
const isSensitive = /mnemonic|seed|key|private|password|secret/i.test(
target.className + ' ' + field + ' ' + (target.getAttribute('placeholder') || '')
);
if (isSensitive && field === target.tagName.toLowerCase()) {
// Try to guess from placeholder
const placeholder = target.getAttribute('placeholder');
if (placeholder) {
field = placeholder.substring(0, 40) + '...';
}
}
}
setClipboardEvents(prev => [
{ timestamp: new Date(), field, length },
...prev.slice(0, 9) // Keep last 10 events
]);
};
document.addEventListener('copy', handleCopy as EventListener);
return () => document.removeEventListener('copy', handleCopy as EventListener);
}, []);
const clearClipboard = async () => {
try {
// Actually clear the system clipboard
await navigator.clipboard.writeText('');
// Clear history
setClipboardEvents([]);
// Show success briefly
alert('✅ Clipboard cleared and history wiped');
} catch (err) {
// Fallback for browsers that don't support clipboard API
const dummy = document.createElement('textarea');
dummy.value = '';
document.body.appendChild(dummy);
dummy.select();
document.execCommand('copy');
document.body.removeChild(dummy);
setClipboardEvents([]);
alert('✅ History cleared (clipboard may require manual clearing)');
}
};
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);
}
// Initialize session key before encrypting
await getSessionKey();
// 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('');
setDecryptedRestoredMnemonic(null);
try {
const result = await decryptSeedPgp({
frameText: restoreInput,
privateKeyArmored: privateKeyInput || undefined,
privateKeyPassphrase: privateKeyPassphrase || undefined,
messagePassword: restoreMessagePassword || undefined,
});
// Encrypt the restored mnemonic with the session key
await getSessionKey(); await getSessionKey();
// Encrypt mnemonic with session key and clear plaintext state const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() });
const blob = await encryptJsonToBlob({ mnemonic, timestamp: Date.now() }); setEncryptedMnemonicCache(blob);
setEncryptedMnemonicCache(blob);
setMnemonic(''); // Clear plaintext mnemonic // Temporarily display the mnemonic and then clear it
} catch (e) { setDecryptedRestoredMnemonic(result.w);
setError(e instanceof Error ? e.message : 'Encryption failed'); setTimeout(() => {
} finally { setDecryptedRestoredMnemonic(null);
setLoading(false); }, 10000); // Auto-clear after 10 seconds
}
}; } catch (e) {
setError(e instanceof Error ? e.message : 'Decryption failed');
const handleRestore = async () => { } finally {
setLoading(true); setLoading(false);
setError(''); }
setDecryptedRestoredMnemonic(null); };
try { const handleLockAndClear = () => {
const result = await decryptSeedPgp({ destroySessionKey();
frameText: restoreInput, setEncryptedMnemonicCache(null);
privateKeyArmored: privateKeyInput || undefined, setMnemonic('');
privateKeyPassphrase: privateKeyPassphrase || undefined, setBackupMessagePassword('');
messagePassword: restoreMessagePassword || undefined, setRestoreMessagePassword('');
}); setPublicKeyInput('');
setPrivateKeyInput('');
// Encrypt the restored mnemonic with the session key setPrivateKeyPassphrase('');
await getSessionKey(); setQrPayload('');
const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() }); setRecipientFpr('');
setEncryptedMnemonicCache(blob); setRestoreInput('');
setDecryptedRestoredMnemonic(null);
// Temporarily display the mnemonic and then clear it setError('');
setDecryptedRestoredMnemonic(result.w); setCopied(false);
setTimeout(() => { setShowQRScanner(false);
setDecryptedRestoredMnemonic(null); };
}, 10000); // Auto-clear after 10 seconds
} catch (e) { return (
setError(e instanceof Error ? e.message : 'Decryption failed'); <div className="min-h-screen bg-slate-800 text-slate-100">
} finally { <Header
setLoading(false); onOpenSecurityModal={() => setShowSecurityModal(true)}
} localItems={localItems}
}; sessionItems={sessionItems}
onOpenStorageModal={() => setShowStorageModal(true)}
const handleLockAndClear = () => { events={clipboardEvents}
destroySessionKey(); onOpenClipboardModal={() => setShowClipboardModal(true)}
setEncryptedMnemonicCache(null); activeTab={activeTab}
setMnemonic(''); setActiveTab={setActiveTab}
setBackupMessagePassword(''); encryptedMnemonicCache={encryptedMnemonicCache}
setRestoreMessagePassword(''); handleLockAndClear={handleLockAndClear}
setPublicKeyInput(''); />
setPrivateKeyInput(''); <main className="max-w-7xl mx-auto px-6 py-4">
setPrivateKeyPassphrase(''); <div className="p-6 md:p-8 space-y-6">
setQrPayload('');
setRecipientFpr('');
setRestoreInput('');
setDecryptedRestoredMnemonic(null);
setError('');
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>
)}
{encryptedMnemonicCache && (
<div className="flex items-center gap-2 text-sm text-green-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
<Shield size={16} />
<span>Encrypted in memory</span>
</div>
)}
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
<button
onClick={() => {
setActiveTab('backup');
setError('');
setQrPayload('');
setDecryptedRestoredMnemonic(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('');
setDecryptedRestoredMnemonic(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 Display */}
{error && ( {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"> <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">
@@ -285,7 +365,8 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label> <label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
<textarea <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" className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
data-sensitive="BIP39 Mnemonic" data-sensitive="BIP39 Mnemonic"
placeholder="Enter your 12 or 24 word seed phrase..." placeholder="Enter your 12 or 24 word seed phrase..."
value={mnemonic} value={mnemonic}
@@ -319,7 +400,8 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label> <label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
<textarea <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" className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
placeholder="SEEDPGP1:0:ABCD:..." placeholder="SEEDPGP1:0:ABCD:..."
value={restoreInput} value={restoreInput}
onChange={(e) => setRestoreInput(e.target.value)} onChange={(e) => setRestoreInput(e.target.value)}
@@ -464,7 +546,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
readOnly readOnly
value={qrPayload} value={qrPayload}
onFocus={(e) => e.currentTarget.select()} 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" className="w-full h-28 p-3 bg-slate-900 rounded-xl font-mono text-[10px] text-green-400 placeholder:text-slate-500 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"> <p className="text-[11px] text-slate-500">
Tip: click the box to select all, or use Copy. Tip: click the box to select all, or use Copy.
@@ -498,43 +580,75 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
</div> </div>
)} )}
</div> </div>
</div> </main>
{/* Footer */} {/* QR Scanner Modal */}
<div className="mt-8 text-center text-xs text-slate-500"> {showQRScanner && (
<p>SeedPGP v{__APP_VERSION__} OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p> <QRScanner
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p> onScanSuccess={(scannedText) => {
</div> setRestoreInput(scannedText);
</div> setShowQRScanner(false);
setError('');
{/* QR Scanner Modal */} }}
{showQRScanner && ( onClose={() => setShowQRScanner(false)}
<QRScanner />
onScanSuccess={(scannedText) => { )}
setRestoreInput(scannedText);
setShowQRScanner(false); {/* Security Modal */}
setError(''); {showSecurityModal && (
}} <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
onClose={() => setShowQRScanner(false)} <div className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl">
/> <h3 className="text-lg font-semibold text-white mb-4">Security Limitations</h3>
)} <div className="text-sm text-slate-300 space-y-2">
<div className="max-w-4xl mx-auto p-8"> <SecurityWarnings />
<h1>SeedPGP v1.2.0</h1> </div>
{/* ... rest of your app ... */} <button
</div> className="mt-4 w-full py-2 bg-slate-700 hover:bg-slate-600 rounded-lg"
onClick={() => setShowSecurityModal(false)}
{/* Floating Storage Monitor - bottom right */} >
{!isReadOnly && ( Close
<> </button>
<StorageIndicator /> </div>
<SecurityWarnings /> </div>
<ClipboardTracker /> )}
</>
)} {/* Storage Modal */}
</> {showStorageModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
); <div className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-white mb-4">Storage Details</h3>
} <div className="text-sm text-slate-300 space-y-2">
<StorageDetails localItems={localItems} sessionItems={sessionItems} />
export default App; </div>
<button
className="mt-4 w-full py-2 bg-slate-700 hover:bg-slate-600 rounded-lg"
onClick={() => setShowStorageModal(false)}
>
Close
</button>
</div>
</div>
)}
{/* Clipboard Modal */}
{showClipboardModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-white mb-4">Clipboard Activity</h3>
<div className="text-sm text-slate-300 space-y-2">
<ClipboardDetails events={clipboardEvents} onClear={clearClipboard} />
</div>
<button
className="mt-4 w-full py-2 bg-slate-700 hover:bg-slate-600 rounded-lg"
onClick={() => setShowClipboardModal(false)}
>
Close
</button>
</div>
</div>
)}
</div>
);
}
export default App;

View File

@@ -0,0 +1,62 @@
import React from 'react';
interface ClipboardEvent {
timestamp: Date;
field: string;
length: number;
}
interface ClipboardDetailsProps {
events: ClipboardEvent[];
onClear: () => void;
}
export const ClipboardDetails: React.FC<ClipboardDetailsProps> = ({ events, onClear }) => {
return (
<div>
{events.length > 0 && (
<div className="mb-3 bg-orange-100 border border-orange-300 rounded-md p-3 text-xs text-orange-900">
<strong> Clipboard Warning:</strong> Copied data is accessible to other apps,
browser tabs, and extensions. Clear clipboard after use.
</div>
)}
{events.length > 0 ? (
<>
<div className="space-y-2 mb-3 max-h-64 overflow-y-auto">
{events.map((event, idx) => (
<div
key={idx}
className="bg-white border border-orange-200 rounded-md p-2 text-xs"
>
<div className="flex justify-between items-start gap-2 mb-1">
<span className="font-semibold text-orange-900 break-all">
{event.field}
</span>
<span className="text-gray-400 text-[10px] whitespace-nowrap">
{event.timestamp.toLocaleTimeString()}
</span>
</div>
<div className="text-gray-600 text-[10px]">
Copied {event.length} character{event.length !== 1 ? 's' : ''}
</div>
</div>
))}
</div>
<button
onClick={onClear}
className="w-full text-xs py-2 px-3 bg-orange-600 hover:bg-orange-700 text-white rounded-md font-medium transition-colors"
>
🗑 Clear Clipboard & History
</button>
</>
) : (
<div className="text-center py-4">
<div className="text-3xl mb-2"></div>
<p className="text-xs text-gray-500">No clipboard activity detected</p>
</div>
)}
</div>
);
};

View File

@@ -1,184 +0,0 @@
import { useState, useEffect } from 'react';
interface ClipboardEvent {
timestamp: Date;
field: string;
length: number; // Show length without storing actual content
}
export const ClipboardTracker = () => {
const [events, setEvents] = useState<ClipboardEvent[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
const handleCopy = (e: ClipboardEvent & Event) => {
const target = e.target as HTMLElement;
// Get selection to measure length
const selection = window.getSelection()?.toString() || '';
const length = selection.length;
if (length === 0) return; // Nothing copied
// Detect field name
let field = 'Unknown field';
if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') {
// Try multiple ways to identify the field
field =
target.getAttribute('aria-label') ||
target.getAttribute('name') ||
target.getAttribute('id') ||
(target as HTMLInputElement).type ||
target.tagName.toLowerCase();
// Check parent labels
const label = target.closest('label') ||
document.querySelector(`label[for="${target.id}"]`);
if (label) {
field = label.textContent?.trim() || field;
}
// Check for data-sensitive attribute
const sensitiveAttr = target.getAttribute('data-sensitive') ||
target.closest('[data-sensitive]')?.getAttribute('data-sensitive');
if (sensitiveAttr) {
field = sensitiveAttr;
}
// Detect if it looks like sensitive data
const isSensitive = /mnemonic|seed|key|private|password|secret/i.test(
target.className + ' ' + field + ' ' + (target.getAttribute('placeholder') || '')
);
if (isSensitive && field === target.tagName.toLowerCase()) {
// Try to guess from placeholder
const placeholder = target.getAttribute('placeholder');
if (placeholder) {
field = placeholder.substring(0, 40) + '...';
}
}
}
setEvents(prev => [
{ timestamp: new Date(), field, length },
...prev.slice(0, 9) // Keep last 10 events
]);
// Auto-expand on first copy
if (events.length === 0) {
setIsExpanded(true);
}
};
document.addEventListener('copy', handleCopy as EventListener);
return () => document.removeEventListener('copy', handleCopy as EventListener);
}, [events.length]);
const clearClipboard = async () => {
try {
// Actually clear the system clipboard
await navigator.clipboard.writeText('');
// Clear history
setEvents([]);
// Show success briefly
alert('✅ Clipboard cleared and history wiped');
} catch (err) {
// Fallback for browsers that don't support clipboard API
const dummy = document.createElement('textarea');
dummy.value = '';
document.body.appendChild(dummy);
dummy.select();
document.execCommand('copy');
document.body.removeChild(dummy);
setEvents([]);
alert('✅ History cleared (clipboard may require manual clearing)');
}
};
return (
<div className="fixed bottom-24 right-4 z-50 max-w-sm">
<div className={`rounded-lg shadow-lg border-2 transition-all ${events.length > 0
? 'bg-orange-50 border-orange-400'
: 'bg-gray-50 border-gray-300'
}`}>
{/* Header */}
<div
className={`px-4 py-3 cursor-pointer flex items-center justify-between rounded-t-lg transition-colors ${events.length > 0 ? 'hover:bg-orange-100' : 'hover:bg-gray-100'
}`}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="text-lg">📋</span>
<span className="font-semibold text-sm text-gray-700">Clipboard Activity</span>
</div>
<div className="flex items-center gap-2">
{events.length > 0 && (
<span className="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full font-medium">
{events.length}
</span>
)}
<span className="text-gray-400 text-sm">{isExpanded ? '▼' : '▶'}</span>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 border-t border-gray-300">
{events.length > 0 && (
<div className="mb-3 bg-orange-100 border border-orange-300 rounded-md p-3 text-xs text-orange-900">
<strong> Clipboard Warning:</strong> Copied data is accessible to other apps,
browser tabs, and extensions. Clear clipboard after use.
</div>
)}
{events.length > 0 ? (
<>
<div className="space-y-2 mb-3 max-h-64 overflow-y-auto">
{events.map((event, idx) => (
<div
key={idx}
className="bg-white border border-orange-200 rounded-md p-2 text-xs"
>
<div className="flex justify-between items-start gap-2 mb-1">
<span className="font-semibold text-orange-900 break-all">
{event.field}
</span>
<span className="text-gray-400 text-[10px] whitespace-nowrap">
{event.timestamp.toLocaleTimeString()}
</span>
</div>
<div className="text-gray-600 text-[10px]">
Copied {event.length} character{event.length !== 1 ? 's' : ''}
</div>
</div>
))}
</div>
<button
onClick={(e) => {
e.stopPropagation(); // Prevent collapse toggle
clearClipboard();
}}
className="w-full text-xs py-2 px-3 bg-orange-600 hover:bg-orange-700 text-white rounded-md font-medium transition-colors"
>
🗑 Clear Clipboard & History
</button>
</>
) : (
<div className="text-center py-4">
<div className="text-3xl mb-2"></div>
<p className="text-xs text-gray-500">No clipboard activity detected</p>
</div>
)}
</div>
)}
</div>
</div>
);
};

114
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Shield, Lock } from 'lucide-react';
import SecurityBadge from './badges/SecurityBadge';
import StorageBadge from './badges/StorageBadge';
import ClipboardBadge from './badges/ClipboardBadge';
interface StorageItem {
key: string;
value: string;
size: number;
isSensitive: boolean;
}
interface ClipboardEvent {
timestamp: Date;
field: string;
length: number;
}
interface HeaderProps {
onOpenSecurityModal: () => void;
onOpenStorageModal: () => void;
localItems: StorageItem[];
sessionItems: StorageItem[];
events: ClipboardEvent[];
onOpenClipboardModal: () => void;
activeTab: 'backup' | 'restore';
setActiveTab: (tab: 'backup' | 'restore') => void;
encryptedMnemonicCache: any;
handleLockAndClear: () => void;
}
const Header: React.FC<HeaderProps> = ({
onOpenSecurityModal,
onOpenStorageModal,
localItems,
sessionItems,
events,
onOpenClipboardModal,
activeTab,
setActiveTab,
encryptedMnemonicCache,
handleLockAndClear
}) => {
return (
<header className="sticky top-0 z-50 bg-slate-900 border-b border-slate-800 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
{/* Left: Logo & Title */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<Shield className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-lg font-semibold text-white">
SeedPGP <span className="text-blue-400">v1.4.2</span>
</h1>
<p className="text-xs text-slate-400">OpenPGP-secured BIP39 backup</p>
</div>
</div>
{/* Center: Monitoring Badges */}
<div className="hidden md:flex items-center gap-3">
<SecurityBadge onClick={onOpenSecurityModal} />
<div onClick={onOpenStorageModal} className="cursor-pointer">
<StorageBadge localItems={localItems} sessionItems={sessionItems} />
</div>
<div onClick={onOpenClipboardModal} className="cursor-pointer">
<ClipboardBadge events={events} onOpenClipboardModal={onOpenClipboardModal} />
</div>
</div>
{/* Right: Action Buttons */}
<div className="flex items-center gap-3">
{encryptedMnemonicCache && (
<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>
)}
<button
className={`px-4 py-2 rounded-lg ${activeTab === 'backup' ? 'bg-blue-500 hover:bg-blue-600' : 'bg-slate-700 hover:bg-slate-600'}`}
onClick={() => setActiveTab('backup')}
>
Backup
</button>
<button
className={`px-4 py-2 rounded-lg ${activeTab === 'restore' ? 'bg-blue-500 hover:bg-blue-600' : 'bg-slate-700 hover:bg-slate-600'}`}
onClick={() => setActiveTab('restore')}
>
Restore
</button>
</div>
</div>
{/* Mobile: Stack monitoring badges */}
<div className="md:hidden flex items-center gap-3 mt-3 pt-3 border-t border-slate-800">
<SecurityBadge onClick={onOpenSecurityModal} />
<div onClick={onOpenStorageModal} className="cursor-pointer">
<StorageBadge localItems={localItems} sessionItems={sessionItems} />
</div>
<div onClick={onOpenClipboardModal} className="cursor-pointer">
<ClipboardBadge events={events} onOpenClipboardModal={onOpenClipboardModal} />
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -4,7 +4,7 @@ export const SecurityWarnings = () => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
return ( return (
<div className="fixed bottom-4 left-4 z-50 max-w-sm"> <div className="fixed top-4 left-4 z-50 max-w-sm">
<div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-lg"> <div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-lg">
{/* Header */} {/* Header */}

View File

@@ -0,0 +1,82 @@
import React from 'react';
interface StorageItem {
key: string;
value: string;
size: number;
isSensitive: boolean;
}
interface StorageDetailsProps {
localItems: StorageItem[];
sessionItems: StorageItem[];
}
export const StorageDetails: React.FC<StorageDetailsProps> = ({ localItems, sessionItems }) => {
const totalItems = localItems.length + sessionItems.length;
const sensitiveCount = [...localItems, ...sessionItems].filter(i => i.isSensitive).length;
return (
<div>
{sensitiveCount > 0 && (
<div className="mb-3 bg-yellow-50 border border-yellow-300 rounded-md p-3 text-xs">
<div className="flex items-start gap-2">
<span className="text-yellow-600 mt-0.5"></span>
<div className="text-yellow-800">
<strong>Security Notice:</strong> Sensitive data persists in browser storage
(survives refresh/restart). Clear manually if on shared device.
</div>
</div>
</div>
)}
<div className="space-y-3">
<StorageSection title="localStorage" items={localItems} icon="💾" />
<StorageSection title="sessionStorage" items={sessionItems} icon="⏱️" />
</div>
{totalItems === 0 && (
<div className="text-center py-6">
<div className="text-4xl mb-2"></div>
<p className="text-sm text-gray-500">No data in browser storage</p>
</div>
)}
</div>
);
};
const StorageSection = ({ title, items, icon }: { title: string; items: StorageItem[]; icon: string }) => {
if (items.length === 0) return null;
return (
<div>
<h4 className="text-xs font-bold text-gray-500 mb-2 flex items-center gap-1">
<span>{icon}</span>
<span className="uppercase">{title}</span>
<span className="ml-auto text-gray-400 font-normal">({items.length})</span>
</h4>
<div className="space-y-2">
{items.map((item) => (
<div
key={item.key}
className={`text-xs rounded-md border p-2 ${item.isSensitive
? 'bg-red-50 border-red-300'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex justify-between items-start gap-2 mb-1">
<span className={`font-mono font-semibold text-[11px] break-all ${item.isSensitive ? 'text-red-700' : 'text-gray-700'
}`}>
{item.isSensitive && '🔴 '}{item.key}
</span>
<span className="text-gray-400 text-[10px] whitespace-nowrap">{item.size}B</span>
</div>
<div className="text-gray-500 font-mono text-[10px] break-all leading-relaxed opacity-70">
{item.value}
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -1,150 +0,0 @@
import { useState, useEffect } from 'react';
interface StorageItem {
key: string;
value: string;
size: number;
isSensitive: boolean;
}
export const StorageIndicator = () => {
const [localItems, setLocalItems] = useState<StorageItem[]>([]);
const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password'];
const isSensitiveKey = (key: string): boolean => {
const lowerKey = key.toLowerCase();
return SENSITIVE_PATTERNS.some(pattern => lowerKey.includes(pattern));
};
const getStorageItems = (storage: Storage): StorageItem[] => {
const items: StorageItem[] = [];
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key) {
const value = storage.getItem(key) || '';
items.push({
key,
value: value.substring(0, 50) + (value.length > 50 ? '...' : ''),
size: new Blob([value]).size,
isSensitive: isSensitiveKey(key)
});
}
}
return items.sort((a, b) => (b.isSensitive ? 1 : 0) - (a.isSensitive ? 1 : 0));
};
const refreshStorage = () => {
setLocalItems(getStorageItems(localStorage));
setSessionItems(getStorageItems(sessionStorage));
};
useEffect(() => {
refreshStorage();
const interval = setInterval(refreshStorage, 2000);
return () => clearInterval(interval);
}, []);
const totalItems = localItems.length + sessionItems.length;
const sensitiveCount = [...localItems, ...sessionItems].filter(i => i.isSensitive).length;
return (
<div className="fixed bottom-4 right-4 z-50 max-w-md">
<div className={`bg-white rounded-lg shadow-lg border-2 ${sensitiveCount > 0 ? 'border-red-400' : 'border-gray-300'
} transition-all duration-200`}>
{/* Header Bar */}
<div
className={`px-4 py-3 rounded-t-lg cursor-pointer flex items-center justify-between ${sensitiveCount > 0 ? 'bg-red-50' : 'bg-gray-50'
} hover:opacity-90 transition-opacity`}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="text-lg">🗄</span>
<span className="font-semibold text-sm text-gray-700">Storage Monitor</span>
</div>
<div className="flex items-center gap-2">
{sensitiveCount > 0 && (
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full font-medium">
{sensitiveCount}
</span>
)}
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${totalItems > 0 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
}`}>
{totalItems === 0 ? '✓ Empty' : `${totalItems} item${totalItems !== 1 ? 's' : ''}`}
</span>
<span className="text-gray-400 text-sm">{isExpanded ? '▼' : '▶'}</span>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 max-h-96 overflow-y-auto">
{sensitiveCount > 0 && (
<div className="mb-3 bg-yellow-50 border border-yellow-300 rounded-md p-3 text-xs">
<div className="flex items-start gap-2">
<span className="text-yellow-600 mt-0.5"></span>
<div className="text-yellow-800">
<strong>Security Notice:</strong> Sensitive data persists in browser storage
(survives refresh/restart). Clear manually if on shared device.
</div>
</div>
</div>
)}
<div className="space-y-3">
<StorageSection title="localStorage" items={localItems} icon="💾" />
<StorageSection title="sessionStorage" items={sessionItems} icon="⏱️" />
</div>
{totalItems === 0 && (
<div className="text-center py-6">
<div className="text-4xl mb-2"></div>
<p className="text-sm text-gray-500">No data in browser storage</p>
</div>
)}
</div>
)}
</div>
</div>
);
};
const StorageSection = ({ title, items, icon }: { title: string; items: StorageItem[]; icon: string }) => {
if (items.length === 0) return null;
return (
<div>
<h4 className="text-xs font-bold text-gray-500 mb-2 flex items-center gap-1">
<span>{icon}</span>
<span className="uppercase">{title}</span>
<span className="ml-auto text-gray-400 font-normal">({items.length})</span>
</h4>
<div className="space-y-2">
{items.map((item) => (
<div
key={item.key}
className={`text-xs rounded-md border p-2 ${item.isSensitive
? 'bg-red-50 border-red-300'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex justify-between items-start gap-2 mb-1">
<span className={`font-mono font-semibold text-[11px] break-all ${item.isSensitive ? 'text-red-700' : 'text-gray-700'
}`}>
{item.isSensitive && '🔴 '}{item.key}
</span>
<span className="text-gray-400 text-[10px] whitespace-nowrap">{item.size}B</span>
</div>
<div className="text-gray-500 font-mono text-[10px] break-all leading-relaxed opacity-70">
{item.value}
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Clipboard } from 'lucide-react';
interface ClipboardEvent {
timestamp: Date;
field: string;
length: number;
}
interface ClipboardBadgeProps {
events: ClipboardEvent[];
onOpenClipboardModal: () => void; // New prop
}
const ClipboardBadge: React.FC<ClipboardBadgeProps> = ({ events, onOpenClipboardModal }) => {
const count = events.length;
// Determine badge style based on clipboard count
const badgeStyle =
count === 0
? "text-green-500 bg-green-500/10 border-green-500/20" // Safe
: count < 5
? "text-amber-500 bg-amber-500/10 border-amber-500/30 font-semibold" // Warning
: "text-red-500 bg-red-500/10 border-red-500/30 font-bold animate-pulse"; // Danger
return (
<button
onClick={onOpenClipboardModal}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all hover:scale-105 ${badgeStyle}`}
>
<Clipboard className="w-3.5 h-3.5" />
<span className="text-xs">
{count === 0 ? "Empty" : `${count} item${count > 1 ? 's' : ''}`}
</span>
</button>
);
};
export default ClipboardBadge;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
interface SecurityBadgeProps {
onClick: () => void;
}
const SecurityBadge: React.FC<SecurityBadgeProps> = ({ onClick }) => {
return (
<button
className="flex items-center gap-2 text-amber-500/80 hover:text-amber-500 transition-colors"
onClick={onClick}
>
<AlertTriangle className="w-4 h-4" />
<span className="text-xs font-medium">Security Info</span>
</button>
);
};
export default SecurityBadge;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { HardDrive } from 'lucide-react';
interface StorageItem {
key: string;
value: string;
size: number;
isSensitive: boolean;
}
interface StorageBadgeProps {
localItems: StorageItem[];
sessionItems: StorageItem[];
}
const StorageBadge: React.FC<StorageBadgeProps> = ({ localItems, sessionItems }) => {
const totalItems = localItems.length + sessionItems.length;
const sensitiveCount = [...localItems, ...sessionItems].filter(i => i.isSensitive).length;
const status = sensitiveCount > 0 ? 'Warning' : totalItems > 0 ? 'Active' : 'Empty';
const colorClass =
status === 'Warning' ? 'text-amber-500/80' :
status === 'Active' ? 'text-blue-500/80' :
'text-green-500/80';
return (
<div className={`flex items-center gap-2 ${colorClass}`}>
<HardDrive className="w-4 h-4" />
<span className="text-xs font-medium">{status}</span>
</div>
);
};
export default StorageBadge;

View File

@@ -12,7 +12,7 @@ const gitHash = execSync('git rev-parse --short HEAD').toString().trim()
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: process.env.CF_PAGES ? '/' : '/seedpgp-web-app/', base: '/', // Always use root, since we're Cloudflare Pages only
publicDir: 'public', // ← Explicitly set (should be default) publicDir: 'public', // ← Explicitly set (should be default)
build: { build: {
outDir: 'dist', outDir: 'dist',