diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index c1e6b02..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -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" diff --git a/src/App.tsx b/src/App.tsx index 07adc9e..5dae2e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,14 @@ import { useState, useEffect } from 'react'; import { - Shield, QrCode, RefreshCw, - CheckCircle2, Lock, + CheckCircle2, + Lock, AlertCircle, Unlock, EyeOff, FileKey, Info, - WifiOff } from 'lucide-react'; import { PgpKeyInput } from './components/PgpKeyInput'; import { QrDisplay } from './components/QrDisplay'; @@ -17,245 +16,326 @@ import QRScanner from './components/QRScanner'; import { validateBip39Mnemonic } from './lib/bip39'; import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp'; import * as openpgp from 'openpgp'; -import { StorageIndicator } from './components/StorageIndicator'; import { SecurityWarnings } from './components/SecurityWarnings'; -import { ClipboardTracker } from './components/ClipboardTracker'; import { ReadOnly } from './components/ReadOnly'; import { getSessionKey, encryptJsonToBlob, 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 [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState(null); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - const [copied, setCopied] = useState(false); - const [showQRScanner, setShowQRScanner] = useState(false); - const [isReadOnly, setIsReadOnly] = useState(false); - const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState(null); - - useEffect(() => { - // When entering read-only mode, clear sensitive data for security. - if (isReadOnly) { - setMnemonic(''); - setBackupMessagePassword(''); - setRestoreMessagePassword(''); - setPublicKeyInput(''); - setPrivateKeyInput(''); - setPrivateKeyPassphrase(''); - setQrPayload(''); - setRestoreInput(''); - setDecryptedRestoredMnemonic(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); - } - - // Initialize session key before encrypting +import Header from './components/Header'; +import { StorageDetails } from './components/StorageDetails'; +import { ClipboardDetails } from './components/ClipboardDetails'; + +console.log("OpenPGP.js version:", openpgp.config.versionString); + +interface StorageItem { + key: string; + value: string; + size: number; + isSensitive: boolean; +} + +interface ClipboardEvent { + timestamp: Date; + field: string; + length: number; +} + +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 [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [showQRScanner, setShowQRScanner] = useState(false); + const [isReadOnly, setIsReadOnly] = useState(false); + const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState(null); + const [showSecurityModal, setShowSecurityModal] = useState(false); + const [showStorageModal, setShowStorageModal] = useState(false); + const [showClipboardModal, setShowClipboardModal] = useState(false); + const [localItems, setLocalItems] = useState([]); + const [sessionItems, setSessionItems] = useState([]); + const [clipboardEvents, setClipboardEvents] = useState([]); + + 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); + }, []); + + useEffect(() => { + // When entering read-only mode, clear sensitive data for security. + if (isReadOnly) { + setMnemonic(''); + setBackupMessagePassword(''); + setRestoreMessagePassword(''); + setPublicKeyInput(''); + setPrivateKeyInput(''); + setPrivateKeyPassphrase(''); + setQrPayload(''); + setRestoreInput(''); + setDecryptedRestoredMnemonic(null); + setError(''); + } + }, [isReadOnly]); + + // 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(); - // 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(); - const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() }); - setEncryptedMnemonicCache(blob); - - // Temporarily display the mnemonic and then clear it - setDecryptedRestoredMnemonic(result.w); - setTimeout(() => { - setDecryptedRestoredMnemonic(null); - }, 10000); // Auto-clear after 10 seconds - - } 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(''); - setDecryptedRestoredMnemonic(null); - setError(''); - setCopied(false); - setShowQRScanner(false); - }; - - - return ( - <> -
-
- - {/* Header */} -
-
-
- -
-
-

- SeedPGP v{__APP_VERSION__} -

-

OpenPGP-secured BIP39 backup

-
-
- {encryptedMnemonicCache && ( // Show only if encrypted data exists - - )} -
- {isReadOnly && ( -
- - Read-only -
- )} - {encryptedMnemonicCache && ( -
- - Encrypted in memory -
- )} -
- - -
-
-
- -
+ const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() }); + setEncryptedMnemonicCache(blob); + + // Temporarily display the mnemonic and then clear it + setDecryptedRestoredMnemonic(result.w); + setTimeout(() => { + setDecryptedRestoredMnemonic(null); + }, 10000); // Auto-clear after 10 seconds + + } 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(''); + setDecryptedRestoredMnemonic(null); + setError(''); + setCopied(false); + setShowQRScanner(false); + }; + + + return ( +
+
setShowSecurityModal(true)} + localItems={localItems} + sessionItems={sessionItems} + onOpenStorageModal={() => setShowStorageModal(true)} + events={clipboardEvents} + onOpenClipboardModal={() => setShowClipboardModal(true)} + activeTab={activeTab} + setActiveTab={setActiveTab} + encryptedMnemonicCache={encryptedMnemonicCache} + handleLockAndClear={handleLockAndClear} + /> +
+
{/* Error Display */} {error && (
@@ -285,7 +365,8 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr