9 Commits

Author SHA1 Message Date
LC mac
eeb5184b8a Cloudflare Pages migration with enforced CSP headers 2026-01-30 02:11:06 +08:00
LC mac
422fe04a12 fix: Copy _headers to dist during build 2026-01-30 01:59:24 +08:00
LC mac
ebeea79a33 chore: Force Cloudflare rebuild with correct base path 2026-01-30 01:49:01 +08:00
LC mac
faf58dc49d fix: Auto-detect base path for Cloudflare vs GitHub Pages 2026-01-30 01:44:53 +08:00
LC mac
46982794cc feat(v1.4): Add 'Encrypted in memory' badge 2026-01-30 01:25:09 +08:00
LC mac
9ffdbbd50f feat(v1.4): Add 'Encrypted in memory' badge 2026-01-30 01:21:28 +08:00
LC mac
b024856c08 docs: update GEMINI.md for v1.4.0 + remove debug logs 2026-01-30 00:44:46 +08:00
LC mac
a919e8bf09 chore: small fix in 1.4.0 2026-01-30 00:36:09 +08:00
LC mac
e4516f3d19 chore: bump version to 1.4.0 2026-01-30 00:35:00 +08:00
7 changed files with 59 additions and 54 deletions

View File

@@ -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
@@ -129,9 +130,18 @@ bun run preview # Preview production build
### Deployment Process ### Deployment Process
This project is now deployed to Cloudflare Pages for enhanced security.
1. **Private repo** (`seedpgp-web`): Source code, development 1. **Private repo** (`seedpgp-web`): Source code, development
2. **Public repo** (`seedpgp-web-app`): Built files for GitHub Pages 2. **Cloudflare Pages**: Deploys from `seedpgp-web` repo directly.
3. **Deploy script** (`scripts/deploy.sh`): Builds + copies to dist/ + pushes to public repo 3. **GitHub Pages (Legacy)**: `seedpgp-web-app` public repo is retained for historical purposes, but no longer actively deployed to.
### Cloudflare Pages Deployment
1. Connect GitHub repo (`seedpgp-web`) to Cloudflare Pages.
2. Build settings: `bun run build`, output directory: `dist/`.
3. `public/_headers` file enforces Content Security Policy (CSP) and other security headers automatically.
4. Benefits: Real CSP enforcement, not just a UI toggle.
### Git Workflow ### Git Workflow
@@ -290,14 +300,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 +379,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

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
{ {
"name": "seedpgp-web", "name": "seedpgp-web",
"private": true, "private": true,
"version": "1.3.0", "version": "1.4.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

6
public/_headers Normal file
View File

@@ -0,0 +1,6 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; form-action 'none'; base-uri 'self';
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin

View File

@@ -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>
)} )}

View File

@@ -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.');
} }

View File

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