mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ffdbbd50f | ||
|
|
b024856c08 | ||
|
|
a919e8bf09 | ||
|
|
e4516f3d19 |
21
GEMINI.md
21
GEMINI.md
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
|
|
||||||
```
|
```BASH
|
||||||
src/
|
src/
|
||||||
├── components/ # React UI components
|
├── components/ # React UI components
|
||||||
│ ├── PgpKeyInput.tsx
|
│ ├── PgpKeyInput.tsx
|
||||||
@@ -104,10 +104,11 @@ Core interfaces:
|
|||||||
- **Clipboard Tracker**: Monitor clipboard operations on sensitive fields
|
- **Clipboard Tracker**: Monitor clipboard operations on sensitive fields
|
||||||
- **Read-only Mode**: Toggle to clear state + show CSP/build info
|
- **Read-only Mode**: Toggle to clear state + show CSP/build info
|
||||||
|
|
||||||
### v1.3 - Session-Key Encryption (Current)
|
### v1.3-v1.4 - Session-Key Encryption
|
||||||
|
|
||||||
- **Ephemeral encryption**: AES-GCM-256 session key (non-exportable) encrypts sensitive state
|
- **Ephemeral encryption**: AES-GCM-256 session key (non-exportable) encrypts sensitive state
|
||||||
- **Auto-clear**: Plaintext mnemonic cleared from UI immediately after QR generation
|
- **Backup flow (v1.3)**: Mnemonic auto-clears immediately after QR generation
|
||||||
|
- **Restore flow (v1.4)**: Decrypted mnemonic auto-clears after 10 seconds + manual Hide button
|
||||||
- **Encrypted cache**: Only ciphertext stored in React state; key lives in memory only
|
- **Encrypted cache**: Only ciphertext stored in React state; key lives in memory only
|
||||||
- **Lock/Clear**: Manual cleanup destroys session key + clears all state
|
- **Lock/Clear**: Manual cleanup destroys session key + clears all state
|
||||||
- **Lifecycle**: Session key auto-destroyed on page close/refresh
|
- **Lifecycle**: Session key auto-destroyed on page close/refresh
|
||||||
@@ -290,14 +291,13 @@ await window.runSessionCryptoTest()
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current Version: v1.3.0
|
## Current Version: v1.4.0
|
||||||
|
|
||||||
### Recent Changes (2026-01-29)
|
### Recent Changes (2026-01-30)
|
||||||
|
- ✅ Extended session-key encryption to Restore flow
|
||||||
- ✅ Added `src/lib/sessionCrypto.ts` with ephemeral AES-GCM session keys
|
- ✅ Added 10-second auto-clear timer for restored mnemonic
|
||||||
- ✅ Integrated into Backup flow: plaintext mnemonic auto-cleared after QR generation
|
- ✅ Added Hide button for manual clear
|
||||||
- ✅ Added Lock/Clear button to destroy session key and clear all state
|
- ✅ Removed debug console logs from sessionCrypto.ts
|
||||||
- ✅ Added cleanup on component unmount
|
|
||||||
|
|
||||||
### Known Limitations
|
### Known Limitations
|
||||||
|
|
||||||
@@ -370,6 +370,7 @@ Check:
|
|||||||
Output: ✅ or ❌ for each item + suggest fixes for failures.
|
Output: ✅ or ❌ for each item + suggest fixes for failures.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2026-01-29
|
**Last Updated**: 2026-01-29
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SeedPGP v1.1</title>
|
<title>SeedPGP v1.4</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "seedpgp-web",
|
"name": "seedpgp-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
60
src/App.tsx
60
src/App.tsx
@@ -6,7 +6,6 @@ import {
|
|||||||
CheckCircle2, Lock,
|
CheckCircle2, Lock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Unlock,
|
Unlock,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FileKey,
|
FileKey,
|
||||||
Info,
|
Info,
|
||||||
@@ -17,7 +16,6 @@ import { QrDisplay } from './components/QrDisplay';
|
|||||||
import QRScanner from './components/QRScanner';
|
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 type { SeedPgpPlaintext } from './lib/types';
|
|
||||||
import * as openpgp from 'openpgp';
|
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';
|
||||||
@@ -40,10 +38,9 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
const [qrPayload, setQrPayload] = useState('');
|
const [qrPayload, setQrPayload] = useState('');
|
||||||
const [recipientFpr, setRecipientFpr] = useState('');
|
const [recipientFpr, setRecipientFpr] = useState('');
|
||||||
const [restoreInput, setRestoreInput] = useState('');
|
const [restoreInput, setRestoreInput] = useState('');
|
||||||
const [restoredData, setRestoredData] = useState<SeedPgpPlaintext | null>(null);
|
const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = 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 [isReadOnly, setIsReadOnly] = useState(false);
|
||||||
@@ -60,7 +57,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
setPrivateKeyPassphrase('');
|
setPrivateKeyPassphrase('');
|
||||||
setQrPayload('');
|
setQrPayload('');
|
||||||
setRestoreInput('');
|
setRestoreInput('');
|
||||||
setRestoredData(null);
|
setDecryptedRestoredMnemonic(null);
|
||||||
setError('');
|
setError('');
|
||||||
}
|
}
|
||||||
}, [isReadOnly]);
|
}, [isReadOnly]);
|
||||||
@@ -138,7 +135,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
const handleRestore = async () => {
|
const handleRestore = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
setRestoredData(null);
|
setDecryptedRestoredMnemonic(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await decryptSeedPgp({
|
const result = await decryptSeedPgp({
|
||||||
@@ -148,8 +145,17 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
messagePassword: restoreMessagePassword || 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
|
||||||
|
|
||||||
setRestoredData(result);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Decryption failed');
|
setError(e instanceof Error ? e.message : 'Decryption failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -169,9 +175,8 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
setQrPayload('');
|
setQrPayload('');
|
||||||
setRecipientFpr('');
|
setRecipientFpr('');
|
||||||
setRestoreInput('');
|
setRestoreInput('');
|
||||||
setRestoredData(null);
|
setDecryptedRestoredMnemonic(null);
|
||||||
setError('');
|
setError('');
|
||||||
setShowMnemonic(false);
|
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
setShowQRScanner(false);
|
setShowQRScanner(false);
|
||||||
};
|
};
|
||||||
@@ -211,13 +216,19 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
<span>Read-only</span>
|
<span>Read-only</span>
|
||||||
</div>
|
</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">
|
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab('backup');
|
setActiveTab('backup');
|
||||||
setError('');
|
setError('');
|
||||||
setQrPayload('');
|
setQrPayload('');
|
||||||
setRestoredData(null);
|
setDecryptedRestoredMnemonic(null);
|
||||||
}}
|
}}
|
||||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
|
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
|
||||||
? 'bg-white text-slate-900 shadow-lg'
|
? 'bg-white text-slate-900 shadow-lg'
|
||||||
@@ -231,7 +242,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
setActiveTab('restore');
|
setActiveTab('restore');
|
||||||
setError('');
|
setError('');
|
||||||
setQrPayload('');
|
setQrPayload('');
|
||||||
setRestoredData(null);
|
setDecryptedRestoredMnemonic(null);
|
||||||
}}
|
}}
|
||||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
|
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
|
||||||
? 'bg-white text-slate-900 shadow-lg'
|
? 'bg-white text-slate-900 shadow-lg'
|
||||||
@@ -463,7 +474,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Restored Mnemonic */}
|
{/* Restored Mnemonic */}
|
||||||
{restoredData && activeTab === 'restore' && (
|
{decryptedRestoredMnemonic && activeTab === 'restore' && (
|
||||||
<div className="pt-6 border-t border-slate-200 animate-in zoom-in-95">
|
<div className="pt-6 border-t border-slate-200 animate-in zoom-in-95">
|
||||||
<div className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
|
<div className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -471,35 +482,18 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
|
|||||||
<CheckCircle2 size={22} /> Mnemonic Recovered
|
<CheckCircle2 size={22} /> Mnemonic Recovered
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMnemonic(!showMnemonic)}
|
onClick={() => setDecryptedRestoredMnemonic(null)}
|
||||||
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
|
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
|
||||||
>
|
>
|
||||||
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />}
|
<EyeOff size={22} /> Hide
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm transition-all duration-300 ${showMnemonic ? 'blur-0' : 'blur-lg select-none'
|
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm">
|
||||||
}`}>
|
|
||||||
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
|
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
|
||||||
{restoredData.w}
|
{decryptedRestoredMnemonic}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{restoredData.pp === 1 && (
|
|
||||||
<div className="mt-4 p-3 bg-orange-100 border border-orange-300 rounded-lg">
|
|
||||||
<p className="text-xs text-center text-orange-800 font-bold uppercase tracking-widest flex items-center justify-center gap-2">
|
|
||||||
<AlertCircle size={14} /> BIP39 Passphrase Required (25th Word)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{restoredData.fpr && restoredData.fpr.length > 0 && (
|
|
||||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<p className="text-xs text-blue-800">
|
|
||||||
<strong>Encrypted for keys:</strong> {restoredData.fpr.join(', ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,13 +56,10 @@ export interface EncryptedBlob {
|
|||||||
* @returns A promise that resolves to the generated or existing CryptoKey.
|
* @returns A promise that resolves to the generated or existing CryptoKey.
|
||||||
*/
|
*/
|
||||||
export async function getSessionKey(): Promise<CryptoKey> {
|
export async function getSessionKey(): Promise<CryptoKey> {
|
||||||
console.log('getSessionKey called.');
|
|
||||||
if (sessionKey) {
|
if (sessionKey) {
|
||||||
console.log('Session key already exists.');
|
|
||||||
return sessionKey;
|
return sessionKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Generating new session key...');
|
|
||||||
const key = await window.crypto.subtle.generateKey(
|
const key = await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
name: KEY_ALGORITHM,
|
name: KEY_ALGORITHM,
|
||||||
@@ -72,7 +69,6 @@ export async function getSessionKey(): Promise<CryptoKey> {
|
|||||||
['encrypt', 'decrypt'],
|
['encrypt', 'decrypt'],
|
||||||
);
|
);
|
||||||
sessionKey = key;
|
sessionKey = key;
|
||||||
console.log('New session key generated and stored.');
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +78,7 @@ export async function getSessionKey(): Promise<CryptoKey> {
|
|||||||
* @returns A promise that resolves to an EncryptedBlob.
|
* @returns A promise that resolves to an EncryptedBlob.
|
||||||
*/
|
*/
|
||||||
export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
|
export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
|
||||||
console.log('encryptJsonToBlob called.');
|
|
||||||
if (!sessionKey) {
|
if (!sessionKey) {
|
||||||
console.error('ERROR: Session key not initialized when encryptJsonToBlob was called.');
|
|
||||||
throw new Error('Session key not initialized. Call getSessionKey() first.');
|
throw new Error('Session key not initialized. Call getSessionKey() first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user