17 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
LC mac
4b5bd80be6 feat(v1.3.0): ephemeral session-key encryption + cleanup
- Update version to 1.3.0
- Remove debug console logs
- Session-key encryption working in production
- Mnemonic auto-clears after QR generation
- Lock/Clear functionality verified
2026-01-30 00:08:43 +08:00
LC mac
8124375537 debug: add console logs to sessionCrypto for troubleshooting 2026-01-30 00:01:02 +08:00
LC mac
2107dab501 feat(v1.3.0): add ephemeral session-key encryption for sensitive state
- Add src/lib/sessionCrypto.ts with AES-GCM-256 non-exportable session keys
- Integrate into Backup flow: auto-clear plaintext mnemonic after QR generation
- Add Lock/Clear button to destroy session key and clear all state
- Add cleanup useEffect on component unmount
- Add comprehensive GEMINI.md for AI agent onboarding
- Fix TypeScript strict mode errors and unused imports

Tested:
- Session-key encryption working (mnemonic clears after QR gen)
- Lock/Clear functionality verified
- No plaintext secrets in localStorage/sessionStorage
- Production build successful
2026-01-29 23:48:21 +08:00
LC mac
0f397859e6 feat(v1.3.0): add ephemeral session-key encryption for sensitive state
- Add src/lib/sessionCrypto.ts with AES-GCM-256 session keys
- Integrate into Backup flow: auto-clear plaintext mnemonic after QR gen
- Add Lock/Clear button to destroy key and clear all state
- Add cleanup on component unmount
- Fix unused imports and TypeScript strict mode errors
2026-01-29 23:35:08 +08:00
LC mac
d4919f3d93 docs: add comprehensive GEMINI.md for AI agent onboarding (v1.3.0) 2026-01-29 23:34:23 +08:00
LC mac
c1b1f566df Fix: Resolve type incompatibility errors in sessionCrypto.ts 2026-01-29 23:24:57 +08:00
LC mac
6bbfe665cd bug fix app.tsx 2026-01-29 23:18:29 +08:00
LC mac
8e656749fe feat(v1.3.0): add ephemeral session-key encryption for sensitive state 2026-01-29 23:14:42 +08:00
11 changed files with 1200 additions and 448 deletions

387
GEMINI.md Normal file
View File

@@ -0,0 +1,387 @@
# SeedPGP - Gemini Code Assist Project Brief
## Project Overview
**SeedPGP v1.3.0**: Client-side BIP39 mnemonic encryption webapp
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
**Deploy**: GitHub Pages (public repo: `seedpgp-web-app`, private source: `seedpgp-web`)
**Live URL**: <https://kccleoc.github.io/seedpgp-web-app/>
## Core Constraints
1. **Security-first**: Never persist secrets (mnemonic/passphrase/private keys) to localStorage/sessionStorage/IndexedDB
2. **Small PRs**: Max 1-5 files per feature; propose plan before coding
3. **Client-side only**: No backend; all crypto runs in browser (Web Crypto API + OpenPGP.js)
4. **GitHub Pages deploy**: Base path `/seedpgp-web-app/` configured in vite.config.ts
5. **Honest security claims**: Don't overclaim what client-side JS can guarantee
## 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 to browser storage
- Prefer "explain what you found in the repo" over guessing
- TypeScript strict mode; no `any` types without justification
---
## Architecture Map
### Entry Points
- `src/main.tsx``src/App.tsx` (main application)
- Build output: `dist/` (separate git repo for GitHub Pages deployment)
### Directory Structure
```BASH
src/
├── components/ # React UI components
│ ├── PgpKeyInput.tsx
│ ├── QrDisplay.tsx
│ ├── QrScanner.tsx
│ ├── ReadOnly.tsx
│ ├── StorageIndicator.tsx
│ ├── SecurityWarnings.tsx
│ └── ClipboardTracker.tsx
├── lib/ # Core logic & crypto utilities
│ ├── seedpgp.ts # Main encrypt/decrypt functions
│ ├── sessionCrypto.ts # Ephemeral AES-GCM session keys
│ ├── types.ts # TypeScript interfaces
│ └── qr.ts # QR code utilities
├── App.tsx # Main app component
└── main.tsx # React entry point
```
### Key Modules
#### `src/lib/seedpgp.ts`
Core encryption/decryption:
- `encryptToSeedPgp()` - Encrypts mnemonic with PGP public key + optional password
- `decryptFromSeedPgp()` - Decrypts with PGP private key + optional password
- Uses OpenPGP.js for PGP operations
- Output format: `SEEDPGP1:version:base64data:fingerprint`
#### `src/lib/sessionCrypto.ts` (v1.3.0+)
Ephemeral session-key encryption:
- `getSessionKey()` - Generates/returns non-exportable AES-GCM-256 key (idempotent)
- `encryptJsonToBlob(obj)` - Encrypts to `{v, alg, iv_b64, ct_b64}`
- `decryptBlobToJson(blob)` - Decrypts back to original object
- `destroySessionKey()` - Drops key reference for garbage collection
- Test: `await window.runSessionCryptoTest()` (DEV only)
#### `src/lib/types.ts`
Core interfaces:
- `SeedPgpPlaintext` - Decrypted mnemonic data structure
- `SeedPgpCiphertext` - Encrypted payload structure
- `EncryptedBlob` - Session-key encrypted cache format
---
## Key Features
### v1.0 - Core Functionality
- **Backup**: Encrypt mnemonic with PGP public key + optional password → QR display
- **Restore**: Scan/paste QR → decrypt with private key → show mnemonic
- **PGP support**: Import public/private keys (.asc files or paste)
### v1.1 - QR Features
- **QR Display**: Generate QR codes from encrypted data
- **QR Scanner**: Camera + file upload (uses html5-qrcode library)
### v1.2 - Security Monitoring
- **Storage Indicator**: Real-time display of localStorage/sessionStorage contents
- **Security Warnings**: Context-aware alerts about browser memory limitations
- **Clipboard Tracker**: Monitor clipboard operations on sensitive fields
- **Read-only Mode**: Toggle to clear state + show CSP/build info
### v1.3-v1.4 - Session-Key Encryption
- **Ephemeral encryption**: AES-GCM-256 session key (non-exportable) encrypts sensitive state
- **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
- **Lock/Clear**: Manual cleanup destroys session key + clears all state
- **Lifecycle**: Session key auto-destroyed on page close/refresh
---
## Development Workflow
### Commands
```bash
bun install # Install dependencies
bun run dev # Dev server (localhost:5173)
bun run build # Build to dist/
bun run typecheck # TypeScript validation (tsc --noEmit)
bun run preview # Preview production build
./scripts/deploy.sh v1.x.x # Build + push to public repo
```
### Deployment Process
This project is now deployed to Cloudflare Pages for enhanced security.
1. **Private repo** (`seedpgp-web`): Source code, development
2. **Cloudflare Pages**: Deploys from `seedpgp-web` repo directly.
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
```bash
# Commit feature
git add src/
git commit -m "feat(v1.x): description"
# Tag version
git tag v1.x.x
git push origin main --tags
# Deploy to GitHub Pages
./scripts/deploy.sh v1.x.x
```
---
## Required Workflow for AI Agents
### 1. Study First
Before implementing any feature:
- Read relevant files
- Explain current architecture + entry points
- List files that will be touched
- Identify potential conflicts or dependencies
### 2. Plan
- Propose smallest vertical slice (1-5 files)
- Show API signatures or interface changes first
- Get approval before generating full implementation
### 3. Implement
- Generate code with TypeScript strict mode
- Include JSDoc comments for public APIs
- Show unified diffs, not full file rewrites (when possible)
- Keep changes under 50-100 lines per file when feasible
### 4. Verify
- Run `bun run typecheck` - no errors
- Run `bun run build` - successful dist/ output
- Provide manual test steps for browser verification
- Show build output / console logs / DevTools screenshots
---
## Common Patterns
### State Management
- React `useState` + `useEffect` (no Redux/Zustand/external store)
- Ephemeral state only; avoid persistent storage for secrets
### Styling
- Tailwind utility classes (configured in `tailwind.config.js`)
- Responsive design: mobile-first with `md:` breakpoints
- Dark theme primary: slate-900 background, blue-400 accents
### Icons
- `lucide-react` library
- Common: Shield, QrCode, Lock, Eye, AlertCircle
### Crypto Operations
- **PGP**: OpenPGP.js (`openpgp` package)
- **Session keys**: Web Crypto API (`crypto.subtle`)
- **Key generation**: `crypto.subtle.generateKey()` with `extractable: false`
- **Encryption**: AES-GCM with random 12-byte IV per operation
### Type Safety
- Strict TypeScript (`tsconfig.json`: `strict: true`)
- Check `src/lib/types.ts` for core interfaces
- Avoid `any`; use `unknown` + type guards when necessary
---
## Security Architecture
### Threat Model (Honest)
**What we protect against:**
- Accidental persistence to localStorage/sessionStorage
- Plaintext secrets lingering in React state after use
- Clipboard history exposure (with warnings)
**What we DON'T protect against (and must not claim to):**
- Active XSS or malicious browser extensions
- Memory dumps or browser crash reports
- JavaScript garbage collection timing (non-deterministic)
### Memory Handling
- **Session keys**: Non-exportable CryptoKey objects (Web Crypto API)
- **Plaintext clearing**: Set to empty string + drop references (but GC timing is non-deterministic)
- **No guarantees**: Cannot force immediate memory wiping in JavaScript
### Storage Policy
- **NEVER write to**: localStorage, sessionStorage, IndexedDB, cookies
- **Exception**: Non-sensitive UI state only (theme preferences, etc.) - NOT IMPLEMENTED YET
- **Verification**: StorageIndicator component monitors all storage APIs
---
## What NOT to Do
### Code Generation
- Don't generate full file rewrites unless necessary
- Don't add dependencies without discussing bundle size impact
- Don't use `any` types without explicit justification
- Don't skip TypeScript strict mode checks
### Security Claims
- Don't claim "RAM is wiped" (JavaScript can't force GC)
- Don't claim "offline mode" without real CSP headers (GitHub Pages can't set custom headers)
- Don't promise protection against active browser compromise (XSS/extensions)
### Storage
- Don't write secrets to storage without explicit approval
- Don't cache decrypted data beyond immediate use
- Don't assume browser storage is secure
---
## Testing & Verification
### Manual Test Checklist (Before Marking Feature Complete)
1.`bun run typecheck` passes (no TypeScript errors)
2.`bun run build` succeeds (dist/ generated)
3. ✅ Browser test: Feature works as described
4. ✅ DevTools Console: No runtime errors
5. ✅ DevTools Application tab: No plaintext secrets in storage
6. ✅ DevTools Network tab: No unexpected network calls (if Read-only Mode)
### Session-Key Encryption Test (v1.3+)
```javascript
// In browser DevTools console:
await window.runSessionCryptoTest()
// Expected: ✅ Success: Data integrity verified.
```
---
## Current Version: v1.4.0
### Recent Changes (2026-01-30)
- ✅ Extended session-key encryption to Restore flow
- ✅ Added 10-second auto-clear timer for restored mnemonic
- ✅ Added Hide button for manual clear
- ✅ Removed debug console logs from sessionCrypto.ts
### Known Limitations
- GitHub Pages cannot set custom CSP headers (need Cloudflare Pages for enforcement)
- Read-only Mode is UI-level only (not browser-enforced)
- Session-key encryption doesn't protect against active XSS/extensions
### Next Priorities (Suggested)
1. Extend session-key encryption to Restore flow
2. Migrate to Cloudflare Pages for real CSP header enforcement
3. Add "Encrypted in memory" badge when encryptedMnemonicCache exists
4. Document reproducible builds (git hash verification)
---
## Quick Reference
### File a Bug/Feature
1. Describe expected vs actual behavior
2. Include browser console errors (if any)
3. Specify which flow (Backup/Restore/QR Scanner)
### Roll Over to Next Session
Always provide:
- Current version number
- What was implemented this session
- Files modified
- What still needs work
- Any gotchas or edge cases discovered
---
## Example Prompts for Gemini
### Exploration
```
Read GEMINI.md, then explain:
1. Where is the mnemonic textarea and how is its value managed?
2. List all places localStorage/sessionStorage are used
3. Show data flow from "Backup" button to QR display
```
### Feature Request
```
Task: [Feature description]
Requirements:
1. [Specific requirement]
2. [Another requirement]
Files to touch:
- [List files]
Plan first: show proposed API/changes before generating code.
```
### Verification
```
Audit the codebase to verify [feature] is fully implemented.
Check:
1. [Requirement 1]
2. [Requirement 2]
Output: ✅ or ❌ for each item + suggest fixes for failures.
```
---
**Last Updated**: 2026-01-29
**Maintained by**: @kccleoc
**AI Agent**: Optimized for Gemini Code Assist

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,12 +1,13 @@
{ {
"name": "seedpgp-web", "name": "seedpgp-web",
"private": true, "private": true,
"version": "1.1.0", "version": "1.4.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",

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

@@ -1,452 +1,540 @@
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,
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';
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';
import { ClipboardTracker } from './components/ClipboardTracker'; import { ClipboardTracker } from './components/ClipboardTracker';
import { ReadOnly } from './components/ReadOnly';
console.log("OpenPGP.js version:", openpgp.config.versionString); import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
function App() { console.log("OpenPGP.js version:", openpgp.config.versionString);
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
const [mnemonic, setMnemonic] = useState(''); function App() {
const [backupMessagePassword, setBackupMessagePassword] = useState(''); const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
const [restoreMessagePassword, setRestoreMessagePassword] = useState(''); const [mnemonic, setMnemonic] = useState('');
const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [publicKeyInput, setPublicKeyInput] = useState(''); const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
const [privateKeyInput, setPrivateKeyInput] = useState('');
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState(''); const [publicKeyInput, setPublicKeyInput] = useState('');
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false); const [privateKeyInput, setPrivateKeyInput] = useState('');
const [qrPayload, setQrPayload] = useState(''); const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
const [recipientFpr, setRecipientFpr] = useState(''); const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
const [restoreInput, setRestoreInput] = useState(''); const [qrPayload, setQrPayload] = useState('');
const [restoredData, setRestoredData] = useState<SeedPgpPlaintext | null>(null); const [recipientFpr, setRecipientFpr] = useState('');
const [error, setError] = useState(''); const [restoreInput, setRestoreInput] = useState('');
const [loading, setLoading] = useState(false); const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null);
const [showMnemonic, setShowMnemonic] = useState(false); const [error, setError] = useState('');
const [copied, setCopied] = useState(false); const [loading, setLoading] = useState(false);
const [showQRScanner, setShowQRScanner] = useState(false); const [copied, setCopied] = useState(false);
const [showQRScanner, setShowQRScanner] = useState(false);
const copyToClipboard = async (text: string) => { const [isReadOnly, setIsReadOnly] = useState(false);
try { const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null);
await navigator.clipboard.writeText(text);
setCopied(true); useEffect(() => {
window.setTimeout(() => setCopied(false), 1500); // When entering read-only mode, clear sensitive data for security.
} catch { if (isReadOnly) {
const ta = document.createElement("textarea"); setMnemonic('');
ta.value = text; setBackupMessagePassword('');
ta.style.position = "fixed"; setRestoreMessagePassword('');
ta.style.left = "-9999px"; setPublicKeyInput('');
document.body.appendChild(ta); setPrivateKeyInput('');
ta.focus(); setPrivateKeyPassphrase('');
ta.select(); setQrPayload('');
document.execCommand("copy"); setRestoreInput('');
document.body.removeChild(ta); setDecryptedRestoredMnemonic(null);
setCopied(true); setError('');
window.setTimeout(() => setCopied(false), 1500); }
} }, [isReadOnly]);
};
// Cleanup session key on component unmount
const handleBackup = async () => { useEffect(() => {
setLoading(true); return () => {
setError(''); destroySessionKey();
setQrPayload(''); };
setRecipientFpr(''); }, []);
try {
const validation = validateBip39Mnemonic(mnemonic); const copyToClipboard = async (text: string) => {
if (!validation.valid) { if (isReadOnly) {
throw new Error(validation.error); setError("Copy to clipboard is disabled in Read-only mode.");
} return;
}
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase); try {
await navigator.clipboard.writeText(text);
const result = await encryptToSeedPgp({ setCopied(true);
plaintext, window.setTimeout(() => setCopied(false), 1500);
publicKeyArmored: publicKeyInput || undefined, } catch {
messagePassword: backupMessagePassword || undefined, // Changed const ta = document.createElement("textarea");
}); ta.value = text;
ta.style.position = "fixed";
setQrPayload(result.framed); ta.style.left = "-9999px";
if (result.recipientFingerprint) { document.body.appendChild(ta);
setRecipientFpr(result.recipientFingerprint); ta.focus();
} ta.select();
} catch (e) { document.execCommand("copy");
setError(e instanceof Error ? e.message : 'Encryption failed'); document.body.removeChild(ta);
} finally { setCopied(true);
setLoading(false); window.setTimeout(() => setCopied(false), 1500);
} }
}; };
const handleRestore = async () => { const handleBackup = async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
setRestoredData(null); setQrPayload('');
setRecipientFpr('');
try {
const result = await decryptSeedPgp({ try {
frameText: restoreInput, const validation = validateBip39Mnemonic(mnemonic);
privateKeyArmored: privateKeyInput || undefined, if (!validation.valid) {
privateKeyPassphrase: privateKeyPassphrase || undefined, throw new Error(validation.error);
messagePassword: restoreMessagePassword || undefined, // Changed }
});
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase);
setRestoredData(result); const result = await encryptToSeedPgp({
} catch (e) { plaintext,
setError(e instanceof Error ? e.message : 'Decryption failed'); publicKeyArmored: publicKeyInput || undefined,
} finally { messagePassword: backupMessagePassword || undefined,
setLoading(false); });
}
}; setQrPayload(result.framed);
if (result.recipientFingerprint) {
setRecipientFpr(result.recipientFingerprint);
return ( }
<>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8"> // Initialize session key before encrypting
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200"> await getSessionKey();
// Encrypt mnemonic with session key and clear plaintext state
{/* Header */} const blob = await encryptJsonToBlob({ mnemonic, timestamp: Date.now() });
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between"> setEncryptedMnemonicCache(blob);
<div className="flex items-center gap-3"> setMnemonic(''); // Clear plaintext mnemonic
<div className="p-2 bg-blue-600 rounded-lg shadow-lg"> } catch (e) {
<Shield size={28} /> setError(e instanceof Error ? e.message : 'Encryption failed');
</div> } finally {
<div> setLoading(false);
<h1 className="text-2xl font-bold tracking-tight"> }
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.2</span> };
</h1>
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p> const handleRestore = async () => {
</div> setLoading(true);
</div> setError('');
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur"> setDecryptedRestoredMnemonic(null);
<button
onClick={() => { try {
setActiveTab('backup'); const result = await decryptSeedPgp({
setError(''); frameText: restoreInput,
setQrPayload(''); privateKeyArmored: privateKeyInput || undefined,
setRestoredData(null); privateKeyPassphrase: privateKeyPassphrase || undefined,
}} messagePassword: restoreMessagePassword || undefined,
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' // Encrypt the restored mnemonic with the session key
}`} await getSessionKey();
> const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() });
Backup setEncryptedMnemonicCache(blob);
</button>
<button // Temporarily display the mnemonic and then clear it
onClick={() => { setDecryptedRestoredMnemonic(result.w);
setActiveTab('restore'); setTimeout(() => {
setError(''); setDecryptedRestoredMnemonic(null);
setQrPayload(''); }, 10000); // Auto-clear after 10 seconds
setRestoredData(null);
}} } catch (e) {
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore' setError(e instanceof Error ? e.message : 'Decryption failed');
? 'bg-white text-slate-900 shadow-lg' } finally {
: 'text-slate-300 hover:text-white hover:bg-slate-700/50' setLoading(false);
}`} }
> };
Restore
</button> const handleLockAndClear = () => {
</div> destroySessionKey();
</div> setEncryptedMnemonicCache(null);
setMnemonic('');
<div className="p-6 md:p-8 space-y-6"> setBackupMessagePassword('');
{/* Error Display */} setRestoreMessagePassword('');
{error && ( setPublicKeyInput('');
<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"> setPrivateKeyInput('');
<AlertCircle className="shrink-0 mt-0.5" size={20} /> setPrivateKeyPassphrase('');
<div> setQrPayload('');
<p className="font-bold mb-1">Error</p> setRecipientFpr('');
<p className="whitespace-pre-wrap">{error}</p> setRestoreInput('');
</div> setDecryptedRestoredMnemonic(null);
</div> setError('');
)} setCopied(false);
setShowQRScanner(false);
{/* Info Banner */} };
{recipientFpr && activeTab === 'backup' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3 text-blue-800 text-xs animate-in fade-in">
<Info size={16} className="shrink-0 mt-0.5" /> return (
<div> <>
<strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code> <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8">
</div> <div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
</div>
)} {/* Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between">
{/* Main Content Grid */} <div className="flex items-center gap-3">
<div className="grid gap-6 md:grid-cols-3"> <div className="p-2 bg-blue-600 rounded-lg shadow-lg">
<div className="md:col-span-2 space-y-6"> <Shield size={28} />
{activeTab === 'backup' ? ( </div>
<> <div>
<div className="space-y-2"> <h1 className="text-2xl font-bold tracking-tight">
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label> SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v{__APP_VERSION__}</span>
<textarea </h1>
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" <p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
data-sensitive="BIP39 Mnemonic" </div>
placeholder="Enter your 12 or 24 word seed phrase..." </div>
value={mnemonic} {encryptedMnemonicCache && ( // Show only if encrypted data exists
onChange={(e) => setMnemonic(e.target.value)} <button
/> onClick={handleLockAndClear}
</div> 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"
>
<PgpKeyInput <Lock size={16} />
label="PGP Public Key (Optional)" <span>Lock/Clear</span>
icon={FileKey} </button>
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..." )}
value={publicKeyInput} <div className="flex items-center gap-4">
onChange={setPublicKeyInput} {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 gap-2"> )}
<button {encryptedMnemonicCache && (
onClick={() => setShowQRScanner(true)} <div className="flex items-center gap-2 text-sm text-green-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
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" <Shield size={16} />
> <span>Encrypted in memory</span>
<QrCode size={18} /> </div>
Scan QR Code )}
</button> <div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
</div> <button
onClick={() => {
<div className="space-y-2"> setActiveTab('backup');
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label> setError('');
<textarea setQrPayload('');
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" setDecryptedRestoredMnemonic(null);
placeholder="SEEDPGP1:0:ABCD:..." }}
value={restoreInput} className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
onChange={(e) => setRestoreInput(e.target.value)} ? 'bg-white text-slate-900 shadow-lg'
/> : 'text-slate-300 hover:text-white hover:bg-slate-700/50'
</div> }`}
>
<PgpKeyInput Backup
label="PGP Private Key (Optional)" </button>
icon={FileKey} <button
data-sensitive="PGP Private Key" onClick={() => {
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..." setActiveTab('restore');
value={privateKeyInput} setError('');
onChange={setPrivateKeyInput} setQrPayload('');
/> setDecryptedRestoredMnemonic(null);
}}
{privateKeyInput && ( className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
<div className="space-y-2"> ? 'bg-white text-slate-900 shadow-lg'
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label> : 'text-slate-300 hover:text-white hover:bg-slate-700/50'
<div className="relative"> }`}
<Lock className="absolute left-3 top-3 text-slate-400" size={16} /> >
<input Restore
type="password" </button>
data-sensitive="Message Password" </div>
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all" </div>
placeholder="Unlock private key..." </div>
value={privateKeyPassphrase}
onChange={(e) => setPrivateKeyPassphrase(e.target.value)} <div className="p-6 md:p-8 space-y-6">
/> {/* Error Display */}
</div> {error && (
</div> <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">
)} <AlertCircle className="shrink-0 mt-0.5" size={20} />
</> <div>
)} <p className="font-bold mb-1">Error</p>
</div> <p className="whitespace-pre-wrap">{error}</p>
</div>
{/* Security Panel */} </div>
<div className="space-y-6"> )}
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-wider flex items-center gap-2"> {/* Info Banner */}
<Lock size={14} /> Security Options {recipientFpr && activeTab === 'backup' && (
</h3> <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3 text-blue-800 text-xs animate-in fade-in">
<Info size={16} className="shrink-0 mt-0.5" />
<div className="space-y-2"> <div>
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label> <strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
<div className="relative"> </div>
<Lock className="absolute left-3 top-3 text-slate-400" size={16} /> </div>
<input )}
type="password"
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all" {/* Main Content Grid */}
placeholder="Optional password..." <div className="grid gap-6 md:grid-cols-3">
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword} <div className="md:col-span-2 space-y-6">
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)} {activeTab === 'backup' ? (
/> <>
</div> <div className="space-y-2">
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p> <label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
</div> <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"
data-sensitive="BIP39 Mnemonic"
{activeTab === 'backup' && ( placeholder="Enter your 12 or 24 word seed phrase..."
<div className="pt-3 border-t border-slate-300"> value={mnemonic}
<label className="flex items-center gap-2 cursor-pointer group"> onChange={(e) => setMnemonic(e.target.value)}
<input readOnly={isReadOnly}
type="checkbox" />
checked={hasBip39Passphrase} </div>
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all" <PgpKeyInput
/> label="PGP Public Key (Optional)"
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors"> icon={FileKey}
BIP39 25th word active placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..."
</span> value={publicKeyInput}
</label> onChange={setPublicKeyInput}
</div> readOnly={isReadOnly}
)} />
</div> </>
) : (
{/* Action Button */} <>
{activeTab === 'backup' ? ( <div className="flex gap-2">
<button <button
onClick={handleBackup} onClick={() => setShowQRScanner(true)}
disabled={!mnemonic || loading} disabled={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="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"
> >
{loading ? ( <QrCode size={18} />
<RefreshCw className="animate-spin" size={20} /> Scan QR Code
) : ( </button>
<QrCode size={20} /> </div>
)}
{loading ? 'Generating...' : 'Generate QR Backup'} <div className="space-y-2">
</button> <label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
) : ( <textarea
<button 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"
onClick={handleRestore} placeholder="SEEDPGP1:0:ABCD:..."
disabled={!restoreInput || loading} value={restoreInput}
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" onChange={(e) => setRestoreInput(e.target.value)}
> readOnly={isReadOnly}
{loading ? ( />
<RefreshCw className="animate-spin" size={20} /> </div>
) : (
<Unlock size={20} /> <PgpKeyInput
)} label="PGP Private Key (Optional)"
{loading ? 'Decrypting...' : 'Decrypt & Restore'} icon={FileKey}
</button> data-sensitive="PGP Private Key"
)} placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..."
</div> value={privateKeyInput}
</div> onChange={setPrivateKeyInput}
readOnly={isReadOnly}
{/* QR Output */} />
{qrPayload && activeTab === 'backup' && (
<div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4"> {privateKeyInput && (
<div className="flex justify-center"> <div className="space-y-2">
<QrDisplay value={qrPayload} /> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
</div> <div className="relative">
<div className="space-y-2"> <Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<div className="flex items-center justify-between gap-3"> <input
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider"> type="password"
Raw payload (copy for backup) data-sensitive="Message Password"
</label> className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="Unlock private key..."
<button value={privateKeyPassphrase}
type="button" onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
onClick={() => copyToClipboard(qrPayload)} readOnly={isReadOnly}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-900 text-white text-xs font-semibold hover:bg-black transition-colors" />
> </div>
{copied ? <CheckCircle2 size={14} /> : <QrCode size={14} />} </div>
{copied ? "Copied" : "Copy"} )}
</button> </>
</div> )}
</div>
<textarea
readOnly {/* Security Panel */}
value={qrPayload} <div className="space-y-6">
onFocus={(e) => e.currentTarget.select()} <div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
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" <h3 className="text-sm font-bold text-slate-800 uppercase tracking-wider flex items-center gap-2">
/> <Lock size={14} /> Security Options
<p className="text-[11px] text-slate-500"> </h3>
Tip: click the box to select all, or use Copy.
</p> <div className="space-y-2">
</div> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
</div> <div className="relative">
)} <Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input
{/* Restored Mnemonic */} type="password"
{restoredData && activeTab === 'restore' && ( className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
<div className="pt-6 border-t border-slate-200 animate-in zoom-in-95"> placeholder="Optional password..."
<div className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg"> value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
<div className="flex items-center justify-between mb-4"> onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
<span className="font-bold text-green-700 flex items-center gap-2 text-lg"> readOnly={isReadOnly}
<CheckCircle2 size={22} /> Mnemonic Recovered />
</span> </div>
<button <p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
onClick={() => setShowMnemonic(!showMnemonic)} </div>
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
>
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />} {activeTab === 'backup' && (
</button> <div className="pt-3 border-t border-slate-300">
</div> <label className="flex items-center gap-2 cursor-pointer group">
<input
<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' type="checkbox"
}`}> checked={hasBip39Passphrase}
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words"> onChange={(e) => setHasBip39Passphrase(e.target.checked)}
{restoredData.w} disabled={isReadOnly}
</p> className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
</div> />
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
{restoredData.pp === 1 && ( BIP39 25th word active
<div className="mt-4 p-3 bg-orange-100 border border-orange-300 rounded-lg"> </span>
<p className="text-xs text-center text-orange-800 font-bold uppercase tracking-widest flex items-center justify-center gap-2"> </label>
<AlertCircle size={14} /> BIP39 Passphrase Required (25th Word) </div>
</p> )}
</div>
)} <ReadOnly
isReadOnly={isReadOnly}
{restoredData.fpr && restoredData.fpr.length > 0 && ( onToggle={setIsReadOnly}
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg"> appVersion={__APP_VERSION__}
<p className="text-xs text-blue-800"> buildHash={__BUILD_HASH__}
<strong>Encrypted for keys:</strong> {restoredData.fpr.join(', ')} />
</p> </div>
</div>
)} {/* Action Button */}
</div> {activeTab === 'backup' ? (
</div> <button
)} onClick={handleBackup}
</div> disabled={!mnemonic || loading || isReadOnly}
</div> 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"
>
{/* Footer */} {loading ? (
<div className="mt-8 text-center text-xs text-slate-500"> <RefreshCw className="animate-spin" size={20} />
<p>SeedPGP v1.2 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> <QrCode size={20} />
</div> )}
</div> {loading ? 'Generating...' : 'Generate QR Backup'}
</button>
{/* QR Scanner Modal */} ) : (
{showQRScanner && ( <button
<QRScanner onClick={handleRestore}
onScanSuccess={(scannedText) => { disabled={!restoreInput || loading || isReadOnly}
setRestoreInput(scannedText); 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"
setShowQRScanner(false); >
setError(''); {loading ? (
}} <RefreshCw className="animate-spin" size={20} />
onClose={() => setShowQRScanner(false)} ) : (
/> <Unlock size={20} />
)} )}
<div className="max-w-4xl mx-auto p-8"> {loading ? 'Decrypting...' : 'Decrypt & Restore'}
<h1>SeedPGP v1.2.0</h1> </button>
{/* ... rest of your app ... */} )}
</div> </div>
</div>
{/* Floating Storage Monitor - bottom right */}
<StorageIndicator /> {/* QR Output */}
<SecurityWarnings /> {/* Bottom-left */} {qrPayload && activeTab === 'backup' && (
<ClipboardTracker /> {/* Top-right */} <div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4">
</> <div className="flex justify-center">
<QrDisplay value={qrPayload} />
); </div>
<div className="space-y-2">
} <div className="flex items-center justify-between gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">
export default App; Raw payload (copy for backup)
</label>
<button
type="button"
onClick={() => copyToClipboard(qrPayload)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-900 text-white text-xs font-semibold hover:bg-black transition-colors"
>
{copied ? <CheckCircle2 size={14} /> : <QrCode size={14} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<textarea
readOnly
value={qrPayload}
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"
/>
<p className="text-[11px] text-slate-500">
Tip: click the box to select all, or use Copy.
</p>
</div>
</div>
)}
{/* Restored Mnemonic */}
{decryptedRestoredMnemonic && activeTab === 'restore' && (
<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="flex items-center justify-between mb-4">
<span className="font-bold text-green-700 flex items-center gap-2 text-lg">
<CheckCircle2 size={22} /> Mnemonic Recovered
</span>
<button
onClick={() => setDecryptedRestoredMnemonic(null)}
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
>
<EyeOff size={22} /> Hide
</button>
</div>
<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">
{decryptedRestoredMnemonic}
</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500">
<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>
</div>
</div>
{/* QR Scanner Modal */}
{showQRScanner && (
<QRScanner
onScanSuccess={(scannedText) => {
setRestoreInput(scannedText);
setShowQRScanner(false);
setError('');
}}
onClose={() => setShowQRScanner(false)}
/>
)}
<div className="max-w-4xl mx-auto p-8">
<h1>SeedPGP v1.2.0</h1>
{/* ... rest of your app ... */}
</div>
{/* Floating Storage Monitor - bottom right */}
{!isReadOnly && (
<>
<StorageIndicator />
<SecurityWarnings />
<ClipboardTracker />
</>
)}
</>
);
}
export default App;

View File

@@ -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>
<span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200"> {!readOnly && (
Drag & Drop .asc file <span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
</span> Drag & Drop .asc file
</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} />

View File

@@ -0,0 +1,39 @@
import { 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
View 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: new Uint8Array(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: new Uint8Array(iv),
},
sessionKey,
new Uint8Array(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;
}

View File

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

@@ -6,3 +6,5 @@ declare module '*.css' {
export default content; export default content;
} }
declare const __APP_VERSION__: string;
declare const __BUILD_HASH__: string;

View File

@@ -1,11 +1,25 @@
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()],
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,
},
define: {
'__APP_VERSION__': JSON.stringify(appVersion),
'__BUILD_HASH__': JSON.stringify(gitHash),
} }
}) })