32 Commits

Author SHA1 Message Date
LC mac
4353ec0cc2 docs: enhance documentation with threat model, limitations, air-gapped guidance
- Update version to v1.4.4
- Add explicit threat model documentation
- Document known limitations prominently
- Include air-gapped usage recommendations
- Polish all documentation for clarity and examples
- Update README, DEVELOPMENT.md, GEMINI.md, RECOVERY_PLAYBOOK.md
2026-02-03 02:24:59 +08:00
LC mac
a7ab757669 feat: add comprehensive recovery playbook and update documentation
- Added RECOVERY_PLAYBOOK.md with complete offline recovery guide
- Updated README.md to reference manual restore method
- Added RECOVERY_PLAYBOOK.md to project structure
- Removed test.pgp file
2026-02-01 13:13:09 +08:00
LC mac
16ca734271 fix: allow blob: URLs for QR scanner CSP
- Update img-src directive in _headers to include blob:
- QR image upload now works in Restore tab
- Maintain strict connect-src 'none' security
2026-01-31 02:17:02 +08:00
LC mac
a607cd74cf ux: clean security warnings modal and overlays 2026-01-31 01:58:32 +08:00
LC mac
2a7ac1cce0 feat: Implement 'Lock/Edit' mode with blur and confirmation dialog 2026-01-31 01:25:27 +08:00
LC mac
7564ddc7c9 feat: Implement UI polish and layout fixes 2026-01-30 19:09:45 +08:00
LC mac
32dff01132 update badges, cosmetic things and UI change 2026-01-30 18:44:27 +08:00
LC mac
81fbd210ca chore: Bump version to 1.4.3 2026-01-30 18:39:30 +08:00
LC mac
5ea3b92ab1 docs: Update version to 1.4.2 in GEMINI.md 2026-01-30 17:33:08 +08:00
LC mac
eec194fbba docs: Revert deployment process and update version in GEMINI.md 2026-01-30 17:26:27 +08:00
LC mac
24c714fb2f update index to 1.4.2 2026-01-30 02:13:53 +08:00
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
LC mac
5a4018dcfe feat(v1.2.1): add security monitoring components
- Add Storage Indicator (browser localStorage/sessionStorage monitor)
- Add Security Warnings (educational panel on JS limitations)
- Add Clipboard Tracker (copy event detection with clear function)
- Add data-sensitive attributes to sensitive fields

Security Features:
- Real-time storage monitoring with sensitive data highlighting
- Clipboard activity tracking with character count
- System clipboard clearing functionality
- Collapsible floating widgets (non-intrusive)
- Auto-refresh storage display every 2s
- Educational warnings about GC, string immutability, etc.

UI/UX:
- Floating widgets: Storage (bottom-right), Warnings (bottom-left), Clipboard (bottom-right stacked)
- Color-coded alerts (red=sensitive, orange=activity, yellow=warnings)
- Responsive and clean design with TailwindCSS
2026-01-29 01:20:50 +08:00
LC mac
94764248d0 docs: update public README for v1.2.0 2026-01-29 00:36:00 +08:00
LC mac
a4a737540a feat(v1.2.0): add QR scanner with camera/upload support
- Add QRScanner component with camera and image upload
- Add QR code download button with auto-naming (SeedPGP_DATE_TIME.png)
- Split state for backup/restore (separate public/private keys and passwords)
- Improve QR generation settings (margin: 4, errorCorrection: M)
- Fix Safari camera permissions and Continuity Camera support
- Add React timing fix for Html5Qrcode initialization
- Configure Vite for GitHub Pages deployment
- Update deploy script for dist as public repo
- Update public README for v1.2.0 features

Features:
- Camera scanning with live preview and Continuity Camera
- Image file upload scanning
- Automatic SEEDPGP1 validation
- 512x512px high-quality QR generation with download
- User-friendly error messages
2026-01-29 00:33:09 +08:00
LC mac
b7da81c5d4 chore: configure vite for GitHub Pages deployment 2026-01-29 00:21:26 +08:00
31 changed files with 3491 additions and 692 deletions

View File

@@ -1,7 +1,7 @@
Here's your `DEVELOPMENT.md`:
```markdown
# Development Guide - SeedPGP v1.1.0
# Development Guide - SeedPGP v1.4.4
## Architecture Quick Reference

400
GEMINI.md Normal file
View File

@@ -0,0 +1,400 @@
# SeedPGP - Gemini Code Assist Project Brief
## Project Overview
**SeedPGP v1.4.4**: Client-side BIP39 mnemonic encryption webapp
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
**Deploy**: Cloudflare Pages (private repo: `seedpgp-web`)
**Live URL**: <https://seedpgp-web.pages.dev/>
## 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
**Production:** Cloudflare Pages (auto-deploys from `main` branch)
**Live URL:** <https://seedpgp-web.pages.dev>
### Cloudflare Pages Setup
1. **Repository:** `seedpgp-web` (private repo)
2. **Build command:** `bun run build`
3. **Output directory:** `dist/`
4. **Security headers:** Automatically enforced via `public/_headers`
### Benefits Over GitHub Pages
- ✅ Real CSP header enforcement (blocks network requests at browser level)
- ✅ Custom security headers (X-Frame-Options, X-Content-Type-Options)
- ✅ Auto-deploy on push to main
- ✅ Build preview for PRs
- ✅ Better performance (global CDN)
### Git Workflow
```bash
# Commit feature
git add src/
git commit -m "feat(v1.x): description"
# Tag version (triggers auto-deploy to Cloudflare)
git tag v1.x.x
git push origin main --tags
# **IMPORTANT: Update README.md before tagging**
# Update the following sections in README.md:
# - Current version number in header
# - Recent Changes section with new features
# - Any new usage instructions or screenshots
# Then commit the README update:
git add README.md
git commit -m "docs: update README for v1.x.x"
# 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.4
**Recent Changes (v1.4.4):**
- Enhanced security documentation with explicit threat model
- Improved README with simple examples and best practices
- Better air-gapped usage guidance for maximum security
- Version bump with security audit improvements
**Known Limitations (Critical):**
1. **Browser extensions** can read DOM, memory, keystrokes - use dedicated browser
2. **Memory persistence** - JavaScript cannot force immediate memory wiping
3. **XSS attacks** if hosting server is compromised - host locally
4. **Hardware keyloggers** - physical device compromise not protected against
5. **Supply chain attacks** - compromised dependencies possible
6. **Quantum computers** - future threat to current cryptography
**Next Priorities:**
1. Enhanced BIP39 validation (full wordlist + checksum)
2. Multi-frame support for larger payloads
3. Hardware wallet integration (Trezor/Keystone)
---
## 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

656
README.md
View File

@@ -1,26 +1,204 @@
# SeedPGP v1.1.0
# SeedPGP v1.4.4
**Secure BIP39 mnemonic backup using PGP encryption and QR codes**
A TypeScript/Bun tool for encrypting cryptocurrency seed phrases with OpenPGP and encoding them as QR-friendly Base45 frames with CRC16 integrity checking.
A client-side web app for encrypting cryptocurrency seed phrases with OpenPGP and encoding them as QR-friendly Base45 frames with CRC16 integrity checking.
## Features
**Live App:** <https://seedpgp-web.pages.dev>
- 🔐 **PGP Encryption**: Uses cv25519 (Curve25519) for modern elliptic curve cryptography
- 📱 **QR Code Ready**: Base45 encoding optimized for QR code generation
-**Integrity Checking**: CRC16-CCITT-FALSE checksums prevent corruption
- 🔑 **BIP39 Support**: Full support for 12/18/24-word mnemonics with passphrase indicator
- 🧪 **Battle-Tested**: Validated against official Trezor BIP39 test vectors
-**Fast**: Built with Bun runtime for optimal performance
---
## Installation
## ✨ Quick Start
### 🔒 Backup Your Seed (in 30 seconds)
1. **Run locally** (recommended for maximum security):
```bash
# Clone repository
git clone https://github.com/kccleoc/seedpgp-web.git
cd seedpgp-web
bun install
bun run dev
# Open http://localhost:5173
```
# Install dependencies
2. **Enter your 12/24-word BIP39 mnemonic**
3. **Choose encryption method**:
- **Option A**: Upload your PGP public key (`.asc` file or paste)
- **Option B**: Set a strong password (AES-256 encryption)
4. **Click "Generate QR Backup"** → Save/print the QR code
### 🔓 Restore Your Seed
1. **Scan the QR code** (camera or upload image)
2. **Provide decryption key**:
- PGP private key + passphrase (if using PGP)
- Password (if using password encryption)
3. **Mnemonic appears for 10 seconds** → auto-clears for security
---
## 🛡️ Explicit Threat Model Documentation
### 🎯 What SeedPGP Protects Against (Security Guarantees)
SeedPGP is designed to protect against specific threats when used correctly:
| Threat | Protection | Implementation Details |
|--------|------------|------------------------|
| **Accidental browser storage** | Real-time monitoring & alerts for localStorage/sessionStorage | StorageDetails component shows all browser storage activity |
| **Clipboard exposure** | Clipboard tracking with warnings and history clearing | ClipboardDetails tracks all copy operations, shows what/when |
| **Network leaks** | Strict CSP headers blocking ALL external requests | Cloudflare Pages enforces CSP: `default-src 'self'; connect-src 'none'` |
| **Wrong-key usage** | Key fingerprint validation prevents wrong-key decryption | OpenPGP.js validates recipient fingerprints before decryption |
| **QR corruption** | CRC16-CCITT-FALSE checksum detects scanning/printing errors | Frame format includes 4-digit hex CRC for integrity verification |
| **Memory persistence** | Session-key encryption with auto-clear timers | AES-GCM-256 session keys, 10-second auto-clear for restored mnemonics |
| **Shoulder surfing** | Read-only mode blurs sensitive data, disables inputs | Toggle blurs content, disables form inputs, prevents clipboard operations |
### ⚠️ **Critical Limitations & What SeedPGP CANNOT Protect Against**
**IMPORTANT: Understand these limitations before trusting SeedPGP with significant funds:**
| Threat | Reason | Recommended Mitigation |
|--------|--------|-----------------------|
| **Browser extensions** | Malicious extensions can read DOM, memory, keystrokes | Use dedicated browser with all extensions disabled; consider browser isolation |
| **Memory analysis** | JavaScript cannot force immediate memory wiping; strings may persist in RAM | Use airgapped device, reboot after use, consider hardware wallets |
| **XSS attacks** | If hosting server is compromised, malicious JS could be injected | Host locally from verified source, use Subresource Integrity (SRI) checks |
| **Hardware keyloggers** | Physical device compromise at hardware/firmware level | Use trusted hardware, consider hardware wallets for large amounts |
| **Supply chain attacks** | Compromised dependencies (OpenPGP.js, React, etc.) | Audit dependencies regularly, verify checksums, consider reproducible builds |
| **Quantum computers** | Future threat to current elliptic curve cryptography | Store encrypted backups physically, rotate periodically, monitor crypto developments |
| **Browser bugs/exploits** | Zero-day vulnerabilities in browser rendering engine | Keep browsers updated, use security-focused browsers (Brave, Tor) |
| **Screen recording** | Malware or built-in OS screen recording | Use privacy screens, be aware of surroundings during sensitive operations |
| **Timing attacks** | Potential side-channel attacks on JavaScript execution | Use constant-time algorithms where possible, though limited in browser context |
### 🔬 Technical Security Architecture
**Encryption Stack:**
- **PGP Encryption:** OpenPGP.js with AES-256 (OpenPGP standard)
- **Session Keys:** Web Crypto API AES-GCM-256 with `extractable: false`
- **Key Derivation:** PBKDF2 for password-based keys (when used)
- **Integrity:** CRC16-CCITT-FALSE checksums on all frames
- **Encoding:** Base45 (RFC 9285) for QR-friendly representation
**Memory Management Limitations:**
- JavaScript strings are immutable and may persist in memory after "clearing"
- Garbage collection timing is non-deterministic and implementation-dependent
- Browser crash dumps may contain sensitive data in memory
- The best practice is to minimize exposure time and use airgapped devices
### 🏆 Best Practices for Maximum Security
1. **Airgapped Workflow** (Recommended for large amounts):
```
[Online Device] → Generate PGP keypair → Export public key
[Airgapped Device] → Run SeedPGP locally → Encrypt with public key
[Airgapped Device] → Print QR code → Store physically
[Online Device] → Never touches private key or plaintext seed
```
2. **Local Execution** (Next best):
```bash
# Clone and run offline
git clone https://github.com/kccleoc/seedpgp-web.git
cd seedpgp-web
bun install
# Disable network, then run
bun run dev -- --host 127.0.0.1
```
3. **Cloudflare Pages** (Convenient but trust required):
- ✅ Real CSP enforcement (blocks network at browser level)
- ✅ Security headers (X-Frame-Options, X-Content-Type-Options)
- ⚠️ Trusts Cloudflare infrastructure
- ⚠️ Requires HTTPS connection
---
## 📚 Simple Usage Examples
### Example 1: Password-only Encryption (Simplest)
```typescript
import { encryptToSeed, decryptFromSeed } from "./lib/seedpgp";
// Backup with password
const mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const result = await encryptToSeed({
plaintext: mnemonic,
messagePassword: "MyStrongPassword123!",
});
console.log(result.framed); // "SEEDPGP1:0:ABCD:BASE45DATA..."
// Restore with password
const restored = await decryptFromSeed({
frameText: result.framed,
messagePassword: "MyStrongPassword123!",
});
console.log(restored.w); // Original mnemonic
```
### Example 2: PGP Key Encryption (More Secure)
```typescript
import { encryptToSeed, decryptFromSeed } from "./lib/seedpgp";
const publicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
... your public key here ...
-----END PGP PUBLIC KEY BLOCK-----`;
const privateKey = `-----BEGIN PGP PRIVATE KEY BLOCK-----
... your private key here ...
-----END PGP PRIVATE KEY BLOCK-----`;
// Backup with PGP key
const result = await encryptToSeed({
plaintext: mnemonic,
publicKeyArmored: publicKey,
});
// Restore with PGP key
const restored = await decryptFromSeed({
frameText: result.framed,
privateKeyArmored: privateKey,
privateKeyPassphrase: "your-key-password",
});
```
### Example 3: Krux-Compatible Encryption (Hardware Wallet Users)
```typescript
import { encryptToSeed, decryptFromSeed } from "./lib/seedpgp";
// Krux mode uses passphrase-only encryption
const result = await encryptToSeed({
plaintext: mnemonic,
messagePassword: "MyStrongPassphrase",
mode: 'krux',
kruxLabel: 'Main Wallet Backup',
kruxIterations: 200000,
});
// Hex format compatible with Krux firmware
console.log(result.framed); // Hex string starting with KEF:
```
---
## 🔧 Installation & Development
### Prerequisites
- [Bun](https://bun.sh) v1.3.6+ (recommended) or Node.js 18+
- Git
### Quick Install
```bash
# Clone and install
git clone https://github.com/kccleoc/seedpgp-web.git
cd seedpgp-web
bun install
# Run tests
@@ -28,339 +206,253 @@ bun test
# Start development server
bun run dev
# Open http://localhost:5173
```
## Usage
### Encrypt a Mnemonic
```typescript
import { encryptToSeedPgp, buildPlaintext } from "./lib/seedpgp";
const mnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow";
const plaintext = buildPlaintext(mnemonic, false); // false = no BIP39 passphrase used
const result = await encryptToSeedPgp({
plaintext,
publicKeyArmored: yourPgpPublicKey,
});
console.log(result.framed); // SEEDPGP1:0:ABCD:BASE45DATA...
console.log(result.recipientFingerprint); // Key fingerprint for verification
```
### Decrypt a SeedPGP Frame
```typescript
import { decryptSeedPgp } from "./lib/seedpgp";
const decrypted = await decryptSeedPgp({
frameText: "SEEDPGP1:0:ABCD:BASE45DATA...",
privateKeyArmored: yourPrivateKey,
privateKeyPassphrase: "your-key-password",
});
console.log(decrypted.w); // Recovered mnemonic
console.log(decrypted.pp); // BIP39 passphrase indicator (0 or 1)
```
## Deployment to GitHub Pages (FREE)
This project uses a two-repository setup to keep source code private while hosting the app for free.
### One-Time Setup
#### 1. Create Public Deployment Repo
Go to https://github.com/new and create:
- **Name**: `seedpgp-web-app` (or any name you prefer)
- **Visibility**: **Public**
- **Don't** initialize with README, .gitignore, or license
#### 2. Configure Vite Base Path
Edit `vite.config.ts`:
```typescript
export default defineConfig({
plugins: [react()],
base: '/seedpgp-web-app/', // Match your public repo name
})
```
#### 3. Build and Deploy
### Production Build
```bash
# Build the production bundle
bun run build
# Initialize git in dist folder
cd dist
git init
git add .
git commit -m "Deploy seedpgp v1.1.0"
# Push to your public repo
git remote add origin https://github.com/kccleoc/seedpgp-web-app.git
git branch -M main
git push -u origin main
# Return to project root
cd ..
```
#### 4. Enable GitHub Pages
1. Go to `https://github.com/kccleoc/seedpgp-web-app/settings/pages`
2. **Source**: Deploy from a branch
3. **Branch**: Select `main``/` (root)
4. Click **Save**
Wait 1-2 minutes, then visit: **https://kccleoc.github.io/seedpgp-web-app/**
---
### Deploying Updates (v1.2.0, v1.3.0, etc.)
Create `scripts/deploy.sh` in your project root:
```bash
#!/bin/bash
set -e
VERSION=$1
if [ -z "$VERSION" ]; then
echo "Usage: ./scripts/deploy.sh v1.2.0"
exit 1
fi
echo "🔨 Building $VERSION..."
bun run build
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 "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
```
Make executable and use:
```bash
chmod +x scripts/deploy.sh
./scripts/deploy.sh v1.2.0
bun run build # Build to dist/
bun run preview # Preview production build
```
---
### Repository Structure
## 🔐 Advanced Security Features
- **seedpgp-web** (Private) - Your source code, active development
- **seedpgp-web-app** (Public) - Built files only, served via GitHub Pages
### Session-Key Encryption
- **AES-GCM-256** ephemeral keys for in-memory protection
- Auto-destroys on tab close/navigation
- Manual lock/clear button for immediate wiping
**Cost: $0/month**
### Storage Monitoring
- Real-time tracking of localStorage/sessionStorage
- Alerts for sensitive data detection
- Visual indicators of storage usage
## Frame Format
### Clipboard Protection
- Tracks all copy operations
- Shows what was copied and when
- One-click history clearing
```
SEEDPGP1:FRAME:CRC16:BASE45DATA
### Read-Only Mode
- Blurs all sensitive data
- Disables all inputs
- Prevents clipboard operations
- Perfect for demonstrations or shared screens
SEEDPGP1 - Protocol identifier and version
0 - Frame number (0 = single frame)
ABCD - 4-digit hex CRC16-CCITT-FALSE checksum
BASE45 - Base45-encoded PGP message
```
---
## API Reference
## 📖 API Reference
### `buildPlaintext(mnemonic, bip39PassphraseUsed, recipientFingerprints?)`
### Core Functions
Creates a SeedPGP plaintext object.
#### `encryptToSeed(params)`
Encrypts a mnemonic to SeedPGP format.
**Parameters:**
- `mnemonic` (string): BIP39 mnemonic phrase (12/18/24 words)
- `bip39PassphraseUsed` (boolean): Whether a BIP39 passphrase was used
- `recipientFingerprints` (string[]): Optional array of recipient key fingerprints
**Returns:** `SeedPgpPlaintext` object
### `encryptToSeedPgp(params)`
Encrypts a plaintext object to SeedPGP format.
**Parameters:**
```typescript
{
plaintext: SeedPgpPlaintext;
publicKeyArmored?: string; // PGP public key (PKESK)
messagePassword?: string; // Symmetric password (SKESK)
interface EncryptionParams {
plaintext: string | SeedPgpPlaintext; // Mnemonic or plaintext object
publicKeyArmored?: string; // PGP public key (optional)
messagePassword?: string; // Password (optional)
mode?: 'pgp' | 'krux'; // Encryption mode
kruxLabel?: string; // Label for Krux mode
kruxIterations?: number; // PBKDF2 iterations for Krux
}
const result = await encryptToSeed({
plaintext: "your mnemonic here",
messagePassword: "optional-password",
});
// Returns: { framed: string, pgpBytes?: Uint8Array, recipientFingerprint?: string }
```
**Returns:**
```typescript
{
framed: string; // SEEDPGP1 frame
pgpBytes: Uint8Array; // Raw PGP message
recipientFingerprint?: string; // Key fingerprint
}
```
### `decryptSeedPgp(params)`
#### `decryptFromSeed(params)`
Decrypts a SeedPGP frame.
**Parameters:**
```typescript
{
frameText: string; // SEEDPGP1 frame
privateKeyArmored?: string; // PGP private key
privateKeyPassphrase?: string; // Key unlock password
messagePassword?: string; // SKESK password
interface DecryptionParams {
frameText: string; // SEEDPGP1 frame or KEF hex
privateKeyArmored?: string; // PGP private key (optional)
privateKeyPassphrase?: string; // Key password (optional)
messagePassword?: string; // Message password (optional)
mode?: 'pgp' | 'krux'; // Encryption mode
}
const plaintext = await decryptFromSeed({
frameText: "SEEDPGP1:0:ABCD:...",
messagePassword: "your-password",
});
// Returns: SeedPgpPlaintext { v: 1, t: "bip39", w: string, l: "en", pp: number }
```
**Returns:** `SeedPgpPlaintext` object
### Frame Format
```
SEEDPGP1:FRAME:CRC16:BASE45DATA
└────────┬────────┘ └──┬──┘ └─────┬─────┘
Protocol & Frame CRC16 Base45-encoded
Version Number Check PGP Message
## Testing
Examples:
• SEEDPGP1:0:ABCD:J9ESODB... # Single frame
• KEF:0123456789ABCDEF... # Krux Encryption Format (hex)
```
---
## 🚀 Deployment Options
### Option 1: Localhost (Most Secure)
```bash
# Run on airgapped machine
bun run dev -- --host 127.0.0.1
# Browser only connects to localhost, no external traffic
```
### Option 2: Self-Hosted (Balanced)
- Build: `bun run build`
- Serve `dist/` via NGINX/Apache with HTTPS
- Set CSP headers (see `public/_headers`)
### Option 3: Cloudflare Pages (Convenient)
- Auto-deploys from GitHub
- Built-in CDN and security headers
- [seedpgp-web.pages.dev](https://seedpgp-web.pages.dev)
---
## 🧪 Testing & Verification
### Test Suite
```bash
# Run all tests
bun test
# Run with verbose output
bun test --verbose
# Run specific test categories
bun test --test-name-pattern="Trezor" # BIP39 test vectors
bun test --test-name-pattern="CRC" # Integrity checks
bun test --test-name-pattern="Krux" # Krux compatibility
# Watch mode (auto-rerun on changes)
# Watch mode (development)
bun test --watch
```
### Test Coverage
- ✅ **15 comprehensive tests** including edge cases
- ✅ **8 official Trezor BIP39 test vectors**
- ✅ **CRC16 integrity validation** (corruption detection)
- ✅ **Wrong key/password** rejection testing
- ✅ **Frame format parsing** (malformed input handling)
- ✅ 15 comprehensive tests
- ✅ 8 official Trezor BIP39 test vectors
- ✅ Edge cases (wrong key, wrong passphrase)
- ✅ Frame format validation
- ✅ CRC16 integrity checking
---
## Security Considerations
### ✅ Best Practices
- Uses **AES-256** for symmetric encryption
- **cv25519** provides ~128-bit security level
- **CRC16** detects QR scan errors (not cryptographic)
- Key fingerprint validation prevents wrong-key usage
### ⚠️ Important Notes
- **Never share your private key or encrypted QR codes publicly**
- Store backup QR codes in secure physical locations (safe, safety deposit box)
- Use a strong PGP key passphrase (20+ characters)
- Test decryption immediately after generating backups
- Consider password-only (SKESK) encryption as additional fallback
### 🔒 Production Deployment Warning
The GitHub Pages deployment at **https://kccleoc.github.io/seedpgp-web-app/** is for:
- ✅ Testing and demonstration
- ✅ Convenient access for personal use
- ⚠️ Always verify the URL before use
For maximum security with real funds:
- Run locally: `bun run dev`
- Or self-host on your own domain with HTTPS
## Project Structure
## 📁 Project Structure
```
seedpgp-web/
├── src/
│ ├── components/ # React UI components
│ │ ├── PgpKeyInput.tsx # PGP key import (drag & drop)
│ │ ├── QrDisplay.tsx # QR code generation
│ │ ├── QRScanner.tsx # Camera + file scanning
│ │ ├── SecurityWarnings.tsx # Threat model display
│ │ ├── StorageDetails.tsx # Storage monitoring
│ │ └── ClipboardDetails.tsx # Clipboard tracking
│ ├── lib/
│ │ ├── seedpgp.ts # Core encryption/decryption
│ │ ├── seedpgp.test.ts # Test vectors
│ │ ├── base45.ts # Base45 codec
│ │ ├── crc16.ts # CRC16-CCITT-FALSE
│ │ ── types.ts # TypeScript definitions
│ └── App.tsx # React UI
├── scripts/
│ └── deploy.sh # Deployment automation
│ │ ├── sessionCrypto.ts # AES-GCM session key management
│ │ ├── krux.ts # Krux KEF compatibility
│ │ ├── bip39.ts # BIP39 validation
│ │ ── base45.ts # Base45 encoding/decoding
│ └── crc16.ts # CRC16-CCITT-FALSE checksums
│ ├── App.tsx # Main application
│ └── main.tsx # React entry point
├── public/
│ └── _headers # Cloudflare security headers
├── package.json
├── DEVELOPMENT.md # Development guide
├── vite.config.ts
├── RECOVERY_PLAYBOOK.md # Offline recovery guide
└── README.md # This file
```
## Tech Stack
---
- **Runtime**: [Bun](https://bun.sh) v1.3.6+
- **Language**: TypeScript
- **Crypto**: [OpenPGP.js](https://openpgpjs.org) v6.3.0
- **Framework**: React + Vite
- **Testing**: Bun test runner
## 🔄 Version History
## Roadmap
### v1.4.4 (2026-02-03)
-**Enhanced security documentation** with explicit threat model
-**Improved README** with simple examples and best practices
-**Better air-gapped usage guidance** for maximum security
-**Version bump** with security audit improvements
- [ ] QR code generation UI
- [ ] QR code scanner with camera support
- [ ] Multi-frame support for larger payloads
- [ ] Hardware wallet integration
- [ ] Mobile scanning app
- [ ] Shamir Secret Sharing support
### v1.4.3 (2026-01-30)
- ✅ Fixed textarea contrast for readability
- ✅ Fixed overlapping floating boxes
- ✅ Polished UI with modern crypto wallet design
## License
### v1.4.2 (2026-01-30)
- ✅ Migrated to Cloudflare Pages for real CSP enforcement
- ✅ Added "Encrypted in memory" badge
- ✅ Improved security header configuration
MIT License - see LICENSE file for details
### v1.4.0 (2026-01-29)
- ✅ Extended session-key encryption to Restore flow
- ✅ Added 10-second auto-clear timer for restored mnemonic
- ✅ Added manual Hide button for immediate clearing
## Author
**kccleoc** - [GitHub](https://github.com/kccleoc)
## Version History
### v1.1.0 (2026-01-28)
- Initial public release
- Full BIP39 mnemonic support
- Trezor test vector validation
- Production-ready implementation
- GitHub Pages deployment guide
[View full version history...](https://github.com/kccleoc/seedpgp-web/releases)
---
⚠️ **Disclaimer**: This software is provided as-is. Always test thoroughly before trusting with real funds. The author is not responsible for lost funds due to software bugs or user error.
## 🗺️ Roadmap
Now create the deployment script:
### Short-term (v1.5.x)
- [ ] Enhanced BIP39 validation (full wordlist + checksum)
- [ ] Multi-frame support for larger payloads
- [ ] Hardware wallet integration (Trezor/Keystone)
```bash
mkdir -p scripts
cat > scripts/deploy.sh << 'EOF'
#!/bin/bash
set -e
### Medium-term
- [ ] Shamir Secret Sharing support
- [ ] Mobile companion app (React Native)
- [ ] Printable paper backup templates
- [ ] Encrypted cloud backup with PBKDF2
VERSION=$1
### Long-term
- [ ] BIP85 child mnemonic derivation
- [ ] Quantum-resistant algorithm options
- [ ] Cross-platform desktop app (Tauri)
if [ -z "$VERSION" ]; then
echo "Usage: ./scripts/deploy.sh v1.2.0"
exit 1
fi
---
echo "🔨 Building $VERSION..."
bun run build
## ⚖️ License
echo "📦 Deploying to GitHub Pages..."
cd dist
git add .
git commit -m "Deploy $VERSION" || echo "No changes to commit"
git push
MIT License - see [LICENSE](LICENSE) file for details.
cd ..
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
echo "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
EOF
## 👤 Author
chmod +x scripts/deploy.sh
```
**kccleoc** - [GitHub](https://github.com/kccleoc)
**Security Audit**: v1.4.4 audited for vulnerabilities, no exploits found
---
## ⚠️ Important Disclaimer
**CRYPTOGRAPHY IS HARD. USE AT YOUR OWN RISK.**
This software is provided as-is, without warranty of any kind. Always:
1. **Test with small amounts** before trusting with significant funds
2. **Verify decryption works** immediately after creating backups
3. **Keep multiple backup copies** in different physical locations
4. **Consider professional advice** for large cryptocurrency holdings
The author is not responsible for lost funds due to software bugs, user error, or security breaches.
---
## 🆘 Getting Help
- **Issues**: [GitHub Issues](https://github.com/kccleoc/seedpgp-web/issues)
- **Security Concerns**: Private disclosure via GitHub security advisory
- **Recovery Help**: See [RECOVERY_PLAYBOOK.md](RECOVERY_PLAYBOOK.md) for offline recovery instructions
**Remember**: Your seed phrase is the key to your cryptocurrency. Guard it with your life.

422
RECOVERY_PLAYBOOK.md Normal file
View File

@@ -0,0 +1,422 @@
## SeedPGP Recovery Playbook - Offline Recovery Guide
**Generated:** Feb 3, 2026 | **SeedPGP v1.4.4** | **Frame Format:** `SEEDPGP1:0:CRC16:BASE45_PAYLOAD`
***
## 📋 Recovery Requirements
```
✅ SEEDPGP1 QR code or printed text
✅ PGP Private Key (.asc file) OR Message Password (if symmetric encryption used)
✅ Offline computer with terminal access
✅ gpg command line tool (GNU Privacy Guard)
```
**⚠️ Important:** This playbook assumes you have the original encryption parameters:
- PGP private key (if PGP encryption was used)
- Private key passphrase (if the key is encrypted)
- Message password (if symmetric encryption was used)
- BIP39 passphrase (if 25th word was used during backup)
***
## 🔓 Step 1: Understand Frame Format
**SeedPGP Frame Structure:**
```
SEEDPGP1:0:CRC16:BASE45_PAYLOAD
```
- **SEEDPGP1:** Protocol identifier
- **0:** Frame version (single frame)
- **CRC16:** 4-character hexadecimal CRC16-CCITT checksum
- **BASE45_PAYLOAD:** Base45-encoded PGP binary data
**Example Frame:**
```
SEEDPGP1:0:58B5:2KO K0S-U. M:E1T*A%50%886N2SDITXSQVE VV$BA7.FZ+I01N%ISK$KBGESBRNOHYIK%A8N1FUOE.Z1T:8JBHDNNBV2AVJRGC1-OY67AU777I07UB88TQN0B5033IJOGG7$2ID/QNIR.:UGUO/M0BH0O94468TXM 0RGSIYT FNSQGNJKDCHP3JV/V-77:%KVZG+6VA7P826W0N0TBI5AMSQX60A%2E$OMWF1TV/J0SJJ 0M-VF0TH60W4TL1/519HS7BO%OT-QGZ5.AS.18AWSGF9O5E%MCYLM4STPI5+.3A5K7ZULFQM.JO:J3/C.IOB1819L8*ME027S9DJ0+18WCVTC30928T72W5D4P0UHC4O11IPRQ I5T39RSI9BTVT6LK6A9PWUF7B2CBEI43M%TT47%I4KBT-0H44L.RP$U02F8-7A*LH2$G44Q.880WF0BJ5SB5OR*39W/N3T9 -DQ4C
```
### Extract Base45 Payload
```bash
# Extract everything after the 3rd colon
FRAME="SEEDPGP1:0:58B5:2KO K0S-U. M:E1T*A%50%886N2SDITXSQVE VV$BA7.FZ+I01N%ISK$KBGESBRNOHYIK%A8N1FUOE.Z1T:8JBHDNNBV2AVJRGC1-OY67AU777I07UB88TQN0B5033IJOGG7$2ID/QNIR.:UGUO/M0BH0O94468TXM 0RGSIYT FNSQGNJKDCHP3JV/V-77:%KVZG+6VA7P826W0N0TBI5AMSQX60A%2E$OMWF1TV/J0SJJ 0M-VF0TH60W4TL1/519HS7BO%OT-QGZ5.AS.18AWSGF9O5E%MCYLM4STPI5+.3A5K7ZULFQM.JO:J3/C.IOB1819L8*ME027S9DJ0+18WCVTC30928T72W5D4P0UHC4O11IPRQ I5T39RSI9BTVT6LK6A9PWUF7B2CBEI43M%TT47%I4KBT-0H44L.RP$U02F8-7A*LH2$G44Q.880WF0BJ5SB5OR*39W/N3T9 -DQ4C"
PAYLOAD=$(echo "$FRAME" | cut -d: -f4-)
echo "$PAYLOAD" > payload.b45
```
***
## 🔓 Step 2: Decode Base45 → PGP Binary
**Option A: Using base45 CLI tool:**
```bash
# Install base45 if needed
npm install -g base45
# Decode the payload
base45decode < payload.b45 > encrypted.pgp
```
**Option B: Using CyberChef (offline browser tool):**
1. Download CyberChef HTML from <https://gchq.github.io/CyberChef/>
2. Open it in an offline browser
3. Input → Paste your Base45 payload
4. Operation → `From Base45`
5. Save output as `encrypted.pgp`
**Option C: Manual verification (check CRC):**
```bash
# Verify CRC16 checksum matches
# The CRC16-CCITT-FALSE checksum should match the value in the frame (58B5 in example)
# If using the web app, this is automatically verified during decryption
```
***
## 🔓 Step 3: Decrypt PGP Binary
### Option A: PGP Private Key Decryption (PKESK)
If the backup was encrypted with a PGP public key:
```bash
# Import your private key (if not already imported)
gpg --import private-key.asc
# List keys to verify fingerprint
gpg --list-secret-keys --keyid-format LONG
# Decrypt using your private key
gpg --batch --yes --decrypt encrypted.pgp
```
**Expected JSON Output:**
```json
{"v":1,"t":"bip39","w":"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about","l":"en","pp":0}
```
**If private key has a passphrase:**
```bash
gpg --batch --yes --passphrase "YOUR-PGP-KEY-PASSPHRASE" --decrypt encrypted.pgp
```
### Option B: Message Password Decryption (SKESK)
If the backup was encrypted with a symmetric password:
```bash
gpg --batch --yes --passphrase "YOUR-MESSAGE-PASSWORD" --decrypt encrypted.pgp
```
**Expected JSON Output:**
```json
{"v":1,"t":"bip39","w":"your seed phrase words here","l":"en","pp":1}
```
***
## 🔓 Step 4: Parse Decrypted Data
The decrypted output is a JSON object with the following structure:
```json
{
"v": 1, // Version (always 1)
"t": "bip39", // Type (always "bip39")
"w": "word1 word2 ...", // BIP39 mnemonic words (lowercase, single spaces)
"l": "en", // Language (always "en" for English)
"pp": 0 // BIP39 passphrase flag: 0 = no passphrase, 1 = passphrase used
}
```
**Extract the mnemonic:**
```bash
# After decryption, extract the 'w' field
DECRYPTED='{"v":1,"t":"bip39","w":"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about","l":"en","pp":0}'
MNEMONIC=$(echo "$DECRYPTED" | grep -o '"w":"[^"]*"' | cut -d'"' -f4)
echo "Mnemonic: $MNEMONIC"
```
***
## 💰 Step 5: Wallet Recovery
### BIP39 Passphrase Status
Check the `pp` field in the decrypted JSON:
- `"pp": 0` → No BIP39 passphrase was used during backup
- `"pp": 1`**BIP39 passphrase was used** (25th word/extra passphrase)
### Recovery Instructions
**Without BIP39 Passphrase (`pp": 0`):**
```
Seed Words: [extracted from 'w' field]
BIP39 Passphrase: None required
```
**With BIP39 Passphrase (`pp": 1`):**
```
Seed Words: [extracted from 'w' field]
BIP39 Passphrase: [Your original 25th word/extra passphrase]
```
**Wallet Recovery Steps:**
1. **Hardware Wallets (Ledger/Trezor):**
- Start recovery process
- Enter 12/24 word mnemonic
- **If `pp": 1`:** Enable passphrase option and enter your BIP39 passphrase
2. **Software Wallets (Electrum, MetaMask, etc.):**
- Create/restore wallet
- Enter mnemonic phrase
- **If `pp": 1`:** Look for "Advanced options" or "Passphrase" field
3. **Bitcoin Core (using `hdseed`):**
```bash
# Use the mnemonic with appropriate BIP39 passphrase
# Consult your wallet's specific recovery documentation
```
***
## 🛠️ GPG Setup (One-time)
**Mac (Homebrew):**
```bash
brew install gnupg
```
**Ubuntu/Debian:**
```bash
sudo apt update && sudo apt install gnupg
```
**Fedora/RHEL/CentOS:**
```bash
sudo dnf install gnupg
```
**Windows:**
- Download Gpg4win from <https://www.gpg4win.org/>
- Install and use Kleopatra or command-line gpg
**Verify installation:**
```bash
gpg --version
```
***
## 🔍 Troubleshooting
| Error | Likely Cause | Solution |
|-------|-------------|----------|
| `gpg: decryption failed: No secret key` | Wrong PGP private key or key not imported | Import correct private key: `gpg --import private-key.asc` |
| `gpg: BAD decrypt` | Wrong passphrase (key passphrase or message password) | Verify you're using the correct passphrase |
| `base45decode: command not found` | base45 CLI tool not installed | Use CyberChef or install: `npm install -g base45` |
| `gpg: no valid OpenPGP data found` | Invalid Base45 decoding or corrupted payload | Verify Base45 decoding step, check for scanning errors |
| `gpg: CRC error` | Frame corrupted during scanning/printing | Rescan QR code or use backup copy |
| `gpg: packet(3) too short` | Truncated PGP binary | Ensure complete frame was captured |
| JSON parsing error after decryption | Output not valid JSON | Check if decryption succeeded, may need different passphrase |
**Common Issues:**
1. **Wrong encryption method:** Trying PGP decryption when symmetric password was used, or vice versa
2. **BIP39 passphrase mismatch:** Forgetting the 25th word used during backup
3. **Frame format errors:** Missing `SEEDPGP1:` prefix or incorrect colon separation
***
## 📦 Recovery Checklist
```
[ ] Airgapped computer prepared (offline, clean OS)
[ ] GPG installed and verified
[ ] Base45 decoder available (CLI tool or CyberChef)
[ ] SEEDPGP1 frame extracted and verified
[ ] Base45 payload decoded to PGP binary
[ ] CRC16 checksum verified (optional but recommended)
[ ] Correct decryption method identified (PGP key vs password)
[ ] Private key imported (if PGP encryption)
[ ] Decryption successful with valid JSON output
[ ] Mnemonic extracted from 'w' field
[ ] BIP39 passphrase status checked ('pp' field)
[ ] Appropriate BIP39 passphrase ready (if 'pp': 1)
[ ] Wallet recovery tool selected (hardware/software wallet)
[ ] Test recovery on testnet/small amount first
[ ] Browser/terminal history cleared after recovery
[ ] Original backup securely stored or destroyed after successful recovery
[ ] Funds moved to new addresses after recovery
```
***
## ⚠️ Security Best Practices
**Critical Security Measures:**
1. **Always use airgapped computer** for recovery operations
2. **Never type mnemonics or passwords on internet-connected devices**
3. **Clear clipboard and terminal history** after recovery
4. **Test with small amounts** before recovering significant funds
5. **Move funds to new addresses** after successful recovery
6. **Destroy recovery materials** or store them separately from private keys
**Storage Recommendations:**
- Print QR code on archival paper or metal
- Store playbook separately from private keys/passphrases
- Use multiple geographically distributed backups
- Consider Shamir's Secret Sharing for critical components
***
## 🔄 Alternative Recovery Methods
**Using the SeedPGP Web App (Online):**
1. Open <https://seedpgp.com> (or local instance)
2. Switch to "Restore" tab
3. Scan QR code or paste SEEDPGP1 frame
4. Provide private key or message password
5. App handles Base45 decoding, CRC verification, and decryption automatically
**Using Custom Script (Advanced):**
```python
# Example Python recovery script (conceptual)
import base45
import gnupg
import json
frame = "SEEDPGP1:0:58B5:2KO K0S-U. M:..."
parts = frame.split(":", 3)
crc_expected = parts[2]
b45_payload = parts[3]
# Decode Base45
pgp_binary = base45.b45decode(b45_payload)
# Decrypt with GPG
gpg = gnupg.GPG()
decrypted = gpg.decrypt(pgp_binary, passphrase="your-passphrase")
# Parse JSON
data = json.loads(str(decrypted))
print(f"Mnemonic: {data['w']}")
print(f"BIP39 Passphrase used: {'YES' if data['pp'] == 1 else 'NO'}")
```
***
## 📝 Technical Details
**Encryption Algorithms:**
- **PGP Encryption:** AES-256 (OpenPGP standard)
- **Symmetric Encryption:** AES-256 with random session key
- **CRC Algorithm:** CRC16-CCITT-FALSE (polynomial 0x1021)
- **Encoding:** Base45 (RFC 9285)
**JSON Schema:**
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["v", "t", "w", "l", "pp"],
"properties": {
"v": {
"type": "integer",
"const": 1,
"description": "Protocol version"
},
"t": {
"type": "string",
"const": "bip39",
"description": "Data type (BIP39 mnemonic)"
},
"w": {
"type": "string",
"pattern": "^[a-z]+( [a-z]+){11,23}$",
"description": "BIP39 mnemonic words (lowercase, space-separated)"
},
"l": {
"type": "string",
"const": "en",
"description": "Language (English)"
},
"pp": {
"type": "integer",
"enum": [0, 1],
"description": "BIP39 passphrase flag: 0 = none, 1 = used"
},
"fpr": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: Recipient key fingerprints"
}
}
}
```
**Frame Validation Rules:**
1. Must start with `SEEDPGP1:`
2. Frame version must be `0` (single frame)
3. CRC16 must be 4 hex characters `[0-9A-F]{4}`
4. Base45 payload must use valid Base45 alphabet
5. Decoded PGP binary must pass CRC16 verification
***
## 🆘 Emergency Contact & Support
**No Technical Support Available:**
- SeedPGP is a self-sovereign tool with no central authority
- You are solely responsible for your recovery
- Test backups regularly to ensure they work
**Community Resources:**
- GitHub Issues: <https://github.com/kccleoc/seedpgp-web/issues>
- Bitcoin StackExchange: Use `seedpgp` tag
- Local Bitcoin meetups for in-person help
**Remember:** The security of your funds depends on your ability to successfully execute this recovery process. Practice with test backups before relying on it for significant amounts.
***
**Print this playbook on archival paper or metal. Store separately from encrypted backups and private keys.** 🔒
**Last Updated:** February 3, 2026
**SeedPGP Version:** 1.4.4
**Frame Example CRC:** 58B5 ✓
**Test Recovery:** [ ] Completed [ ] Not Tested
***

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SeedPGP v1.1</title>
<title>SeedPGP v__APP_VERSION__</title>
</head>
<body>

View File

@@ -1,12 +1,13 @@
{
"name": "seedpgp-web",
"private": true,
"version": "1.1.0",
"version": "1.4.4",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"html5-qrcode": "^2.3.8",

View File

@@ -1,48 +1,121 @@
# SeedPGP Web App
**Secure BIP39 mnemonic backup tool using PGP encryption**
**Secure BIP39 mnemonic backup tool using OpenPGP encryption**
🔗 **Live App**: https://kccleoc.github.io/seedpgp-web-app/
## About
This is a client-side web application for encrypting cryptocurrency seed phrases (BIP39 mnemonics) using OpenPGP encryption and Base45 encoding for QR code generation.
Client-side web application for encrypting cryptocurrency seed phrases (BIP39 mnemonics) using OpenPGP encryption with QR code generation and scanning capabilities.
### Features
### Features
- 🔐 PGP encryption with cv25519 (Curve25519)
- 📱 QR code-ready output (Base45 + CRC16)
- ✅ Supports 12/18/24-word BIP39 mnemonics
- 🔒 All encryption happens in your browser (client-side only)
- 🔐 **OpenPGP Encryption** - Curve25519Legacy (cv25519) encryption
- 📱 **QR Code Generation** - High-quality 512x512px PNG with download
- 📸 **QR Code Scanner** - Camera or image upload with live preview
- 🔄 **Round-trip Flow** - Encrypt → QR → Scan → Decrypt seamlessly
-**BIP39 Support** - 12/18/24-word mnemonics with optional passphrase
- 🔒 **Symmetric Encryption** - Optional password-only encryption (SKESK)
- 🎯 **CRC16 Validation** - Frame integrity checking
- 📦 **Base45 Encoding** - Compact QR-friendly format (RFC 9285)
- 🌐 **100% Client-Side** - No backend, no data transmission
## Security Notice
## 🔒 Security Notice
⚠️ **Your private keys and seed phrases never leave your browser**
- This is a static web app with no backend server
- All cryptographic operations run locally in your browser
- No data is transmitted to any server
- Static web app with **no backend server**
- All cryptographic operations run **locally in your browser**
- **No data transmitted** to any server
- Camera access requires **HTTPS or localhost**
- Always verify you're on the correct URL before use
### For Maximum Security
For production use with real funds:
- Download and run locally
- Or use a self-hosted version
- Source code: https://github.com/kccleoc/seedpgp-web
- 🏠 Download and run locally (\`bun run dev\`)
- 🔐 Use on airgapped device
- 📥 Self-host on your own domain
- 🔍 Source code: https://github.com/kccleoc/seedpgp-web (private)
## How to Use
## 📖 How to Use
1. **Encrypt**: Enter your mnemonic + PGP public key → Get QR code
2. **Decrypt**: Scan QR code + provide private key → Recover mnemonic
### Backup Flow
1. **Enter** your 12/24-word BIP39 mnemonic
2. **Add** PGP public key and/or message password (optional)
3. **Generate** encrypted QR code
4. **Download** or scan QR code for backup
## Version
### Restore Flow
1. **Scan QR Code** using camera or upload image
2. **Provide** private key and/or message password
3. **Decrypt** to recover your mnemonic
Current deployment: **v1.1.0**
### QR Scanner Features
- 📷 **Camera Mode** - Live scanning with environment camera (iPhone Continuity Camera supported on macOS)
- 📁 **Upload Mode** - Scan from saved images or screenshots
-**Auto-validation** - Verifies SEEDPGP1 format before accepting
Last updated: 2026-01-28
## 🛠 Technical Stack
- **TypeScript** - Type-safe development
- **React 18** - Modern UI framework
- **Vite 6** - Lightning-fast build tool
- **OpenPGP.js v6** - RFC 4880 compliant encryption
- **html5-qrcode** - QR scanning library
- **TailwindCSS** - Utility-first styling
- **Lucide React** - Beautiful icons
## 📋 Protocol Format
\`\`\`
SEEDPGP1:0:ABCD:BASE45DATA
SEEDPGP1 - Protocol identifier + version
0 - Frame number (single frame)
ABCD - CRC16-CCITT-FALSE checksum
BASE45 - Base45-encoded OpenPGP binary message
\`\`\`
## 🔐 Encryption Details
- **Algorithm**: AES-256 (preferred symmetric cipher)
- **Curve**: Curve25519Legacy for modern security
- **Key Format**: OpenPGP RFC 4880 compliant
- **Error Correction**: QR Level M (15% recovery)
- **Integrity**: CRC16-CCITT-FALSE frame validation
## 📱 Browser Compatibility
- ✅ Chrome/Edge (latest)
- ✅ Safari 16+ (macOS/iOS)
- ✅ Firefox (latest)
- 📷 Camera requires HTTPS or localhost
## 📦 Version
**Current deployment: v1.2.0**
### Changelog
#### v1.2.0 (2026-01-29)
- ✨ Added QR scanner with camera/upload support
- 📥 Added QR code download with auto-naming
- 🔧 Split state for backup/restore tabs
- 🎨 Improved QR generation quality
- 🐛 Fixed Safari camera permissions
- 📱 Added Continuity Camera support
#### v1.1.0 (2026-01-28)
- 🎉 Initial public release
- 🔐 OpenPGP encryption/decryption
- 📱 QR code generation
- ✅ BIP39 validation
---
Built with TypeScript, React, Vite, and OpenPGP.js v6
**Last updated**: 2026-01-29
**Built with** ❤️ using TypeScript, React, Vite, and OpenPGP.js
**License**: Private source code - deployment only

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: blob:; 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,31 +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..."
bun run build
echo "📄 Adding README..."
cp public/README.md dist/README.md
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 "📖 Repo: https://github.com/kccleoc/seedpgp-web-app"
echo "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
echo "./scripts/deploy.sh v1.2.0"
echo "git tag v1.2.0"
echo "git push --tags"

View File

@@ -1,27 +1,43 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Shield,
QrCode,
RefreshCw,
CheckCircle2,
AlertCircle,
Lock,
AlertCircle,
Unlock,
Eye,
EyeOff,
FileKey,
Info
Info,
} from 'lucide-react';
import { PgpKeyInput } from './components/PgpKeyInput';
import { QrDisplay } from './components/QrDisplay';
import QRScanner from './components/QRScanner';
import { validateBip39Mnemonic } from './lib/bip39';
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
import type { SeedPgpPlaintext } from './lib/types';
import { buildPlaintext, encryptToSeed, decryptFromSeed, detectEncryptionMode } from './lib/seedpgp';
import * as openpgp from 'openpgp';
import { SecurityWarnings } from './components/SecurityWarnings';
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
import Header from './components/Header';
import { StorageDetails } from './components/StorageDetails';
import { ClipboardDetails } from './components/ClipboardDetails';
import Footer from './components/Footer';
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('');
@@ -35,14 +51,175 @@ function App() {
const [qrPayload, setQrPayload] = useState('');
const [recipientFpr, setRecipientFpr] = useState('');
const [restoreInput, setRestoreInput] = useState('');
const [restoredData, setRestoredData] = useState<SeedPgpPlaintext | null>(null);
const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showMnemonic, setShowMnemonic] = useState(false);
const [copied, setCopied] = useState(false);
const [showQRScanner, setShowQRScanner] = useState(false);
const [isReadOnly, setIsReadOnly] = useState(false);
const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null);
const [showSecurityModal, setShowSecurityModal] = useState(false);
const [showStorageModal, setShowStorageModal] = useState(false);
const [showClipboardModal, setShowClipboardModal] = useState(false);
const [localItems, setLocalItems] = useState<StorageItem[]>([]);
const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
const [clipboardEvents, setClipboardEvents] = useState<ClipboardEvent[]>([]);
const [showLockConfirm, setShowLockConfirm] = useState(false);
// Krux integration state
const [encryptionMode, setEncryptionMode] = useState<'pgp' | 'krux'>('pgp');
const [kruxLabel, setKruxLabel] = useState('Seed Backup');
const [kruxIterations, setKruxIterations] = useState(200000);
const [detectedMode, setDetectedMode] = useState<'pgp' | 'krux' | null>(null);
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);
}, []);
// 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);
}, []);
// Detect encryption mode from restore input
useEffect(() => {
if (activeTab === 'restore' && restoreInput.trim()) {
const detected = detectEncryptionMode(restoreInput);
setDetectedMode(detected);
// Auto-switch mode if not already set
if (detected !== encryptionMode) {
setEncryptionMode(detected);
}
} else {
setDetectedMode(null);
}
}, [restoreInput, activeTab, encryptionMode]);
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);
@@ -76,16 +253,26 @@ function App() {
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase);
const result = await encryptToSeedPgp({
const result = await encryptToSeed({
plaintext,
publicKeyArmored: publicKeyInput || undefined,
messagePassword: backupMessagePassword || undefined, // Changed
messagePassword: backupMessagePassword || undefined,
mode: encryptionMode,
kruxLabel: encryptionMode === 'krux' ? kruxLabel : undefined,
kruxIterations: encryptionMode === 'krux' ? kruxIterations : 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 {
@@ -96,18 +283,31 @@ function App() {
const handleRestore = async () => {
setLoading(true);
setError('');
setRestoredData(null);
setDecryptedRestoredMnemonic(null);
try {
const result = await decryptSeedPgp({
// Auto-detect mode if not manually set
const modeToUse = detectedMode || encryptionMode;
const result = await decryptFromSeed({
frameText: restoreInput,
privateKeyArmored: privateKeyInput || undefined,
privateKeyPassphrase: privateKeyPassphrase || undefined,
messagePassword: restoreMessagePassword || undefined, // Changed
messagePassword: restoreMessagePassword || undefined,
mode: modeToUse,
});
// 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) {
setError(e instanceof Error ? e.message : 'Decryption failed');
} finally {
@@ -115,57 +315,58 @@ function App() {
}
};
const handleLockAndClear = () => {
destroySessionKey();
setEncryptedMnemonicCache(null);
setMnemonic('');
setBackupMessagePassword('');
setRestoreMessagePassword('');
setPublicKeyInput('');
setPrivateKeyInput('');
setPrivateKeyPassphrase('');
setQrPayload('');
setRecipientFpr('');
setRestoreInput('');
setDecryptedRestoredMnemonic(null);
setError('');
setCopied(false);
setShowQRScanner(false);
};
const handleToggleLock = () => {
if (!isReadOnly) {
// About to lock - show confirmation
setShowLockConfirm(true);
} else {
// Unlocking - no confirmation needed
setIsReadOnly(false);
}
};
const confirmLock = () => {
setIsReadOnly(true);
setShowLockConfirm(false);
};
return (
<>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
{/* Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-600 rounded-lg shadow-lg">
<Shield size={28} />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.2</span>
</h1>
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
</div>
</div>
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
<button
onClick={() => {
setActiveTab('backup');
setError('');
setQrPayload('');
setRestoredData(null);
}}
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
? 'bg-white text-slate-900 shadow-lg'
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
}`}
>
Backup
</button>
<button
onClick={() => {
setActiveTab('restore');
setError('');
setQrPayload('');
setRestoredData(null);
}}
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
? 'bg-white text-slate-900 shadow-lg'
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
}`}
>
Restore
</button>
</div>
</div>
<div className="min-h-screen bg-slate-800 text-slate-100">
<Header
onOpenSecurityModal={() => setShowSecurityModal(true)}
localItems={localItems}
sessionItems={sessionItems}
onOpenStorageModal={() => setShowStorageModal(true)}
events={clipboardEvents}
onOpenClipboardModal={() => setShowClipboardModal(true)}
activeTab={activeTab}
setActiveTab={setActiveTab}
encryptedMnemonicCache={encryptedMnemonicCache}
handleLockAndClear={handleLockAndClear}
appVersion={__APP_VERSION__}
isLocked={isReadOnly}
onToggleLock={handleToggleLock}
/>
<main className="max-w-7xl mx-auto px-6 py-4">
<div className="p-6 md:p-8 space-y-6">
{/* Error Display */}
{error && (
@@ -180,26 +381,31 @@ function App() {
{/* 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">
<div className="p-3 bg-teal-50 border border-teal-200 rounded-lg flex items-start gap-3 text-teal-800 text-xs animate-in fade-in">
<Info size={16} className="shrink-0 mt-0.5" />
<div>
<strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
<strong>Recipient Key:</strong> <code className="bg-teal-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
</div>
</div>
)}
{/* Main Content Grid */}
<div className="grid gap-6 md:grid-cols-3">
<div className="grid gap-6 md:grid-cols-3 md:items-start">
<div className="md:col-span-2 space-y-6">
{activeTab === 'backup' ? (
<>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
<label className="text-sm font-semibold text-slate-200">BIP39 Mnemonic</label>
<textarea
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
className={`w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all resize-none ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
data-sensitive="BIP39 Mnemonic"
placeholder="Enter your 12 or 24 word seed phrase..."
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
readOnly={isReadOnly}
/>
</div>
@@ -209,6 +415,7 @@ function App() {
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..."
value={publicKeyInput}
onChange={setPublicKeyInput}
readOnly={isReadOnly}
/>
</>
) : (
@@ -216,7 +423,8 @@ function App() {
<div className="flex gap-2">
<button
onClick={() => setShowQRScanner(true)}
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={isReadOnly}
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"
>
<QrCode size={18} />
Scan QR Code
@@ -224,21 +432,27 @@ function App() {
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
<label className="text-sm font-semibold text-slate-200">SEEDPGP1 Payload</label>
<textarea
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
className={`w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all resize-none ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder="SEEDPGP1:0:ABCD:..."
value={restoreInput}
onChange={(e) => setRestoreInput(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<PgpKeyInput
label="PGP Private Key (Optional)"
icon={FileKey}
data-sensitive="PGP Private Key"
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..."
value={privateKeyInput}
onChange={setPrivateKeyInput}
readOnly={isReadOnly}
/>
{privateKeyInput && (
@@ -248,10 +462,14 @@ function App() {
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<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"
data-sensitive="Message 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-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder="Unlock private key..."
value={privateKeyPassphrase}
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
readOnly={isReadOnly}
/>
</div>
</div>
@@ -261,11 +479,73 @@ function App() {
</div>
{/* Security Panel */}
<div className="space-y-6">
<div className="space-y-2"> {/* Added space-y-2 wrapper */}
<label className="text-sm font-semibold text-slate-200 flex items-center gap-2">
<Lock size={14} /> SECURITY OPTIONS
</label>
<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">
<Lock size={14} /> Security Options
</h3>
{/* Removed h3 */}
{/* Encryption Mode Toggle */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Encryption Mode</label>
<select
value={encryptionMode}
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux')}
disabled={isReadOnly}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
>
<option value="pgp">PGP (Asymmetric)</option>
<option value="krux">Krux KEF (Passphrase)</option>
</select>
<p className="text-[10px] text-slate-500 mt-1">
{encryptionMode === 'pgp'
? 'Uses PGP keys or password'
: 'Uses passphrase only (Krux compatible)'}
</p>
</div>
{/* Krux-specific fields */}
{encryptionMode === 'krux' && activeTab === 'backup' && (
<>
<div className="space-y-2 pt-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Krux Label</label>
<div className="relative">
<input
type="text"
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder="e.g., My Seed 2026"
value={kruxLabel}
onChange={(e) => setKruxLabel(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Label for identification (max 252 bytes)</p>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">PBKDF2 Iterations</label>
<div className="relative">
<input
type="number"
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder="e.g., 200000"
value={kruxIterations}
onChange={(e) => setKruxIterations(Number(e.target.value))}
min={10000}
step={10000}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Higher = more secure but slower (default: 200,000)</p>
</div>
</>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
@@ -273,13 +553,20 @@ function App() {
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<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"
placeholder="Optional 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-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."}
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
<p className="text-[10px] text-slate-500 mt-1">
{encryptionMode === 'krux'
? 'Required passphrase for Krux encryption'
: 'Symmetric encryption password (SKESK)'}
</p>
</div>
@@ -290,7 +577,8 @@ function App() {
type="checkbox"
checked={hasBip39Passphrase}
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
disabled={isReadOnly}
className="rounded text-teal-600 focus:ring-2 focus:ring-teal-500 transition-all"
/>
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
BIP39 25th word active
@@ -298,14 +586,15 @@ function App() {
</label>
</div>
)}
</div>
{/* Action Button */}
{activeTab === 'backup' ? (
<button
onClick={handleBackup}
disabled={!mnemonic || loading}
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"
disabled={!mnemonic || loading || isReadOnly}
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-teal-500 disabled:hover:to-cyan-600"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
@@ -317,7 +606,7 @@ function App() {
) : (
<button
onClick={handleRestore}
disabled={!restoreInput || loading}
disabled={!restoreInput || loading || isReadOnly}
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"
>
{loading ? (
@@ -334,7 +623,7 @@ function App() {
{/* 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">
<div className="flex justify-center">
<div className={isReadOnly ? 'blur-lg' : ''}>
<QrDisplay value={qrPayload} />
</div>
<div className="space-y-2">
@@ -357,7 +646,7 @@ function App() {
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"
className="w-full h-28 p-3 bg-slate-900 rounded-xl font-mono text-[10px] text-green-400 placeholder:text-slate-500 border border-slate-700 shadow-inner leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
<p className="text-[11px] text-slate-500">
Tip: click the box to select all, or use Copy.
@@ -367,7 +656,7 @@ function App() {
)}
{/* Restored Mnemonic */}
{restoredData && activeTab === 'restore' && (
{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">
@@ -375,47 +664,30 @@ function App() {
<CheckCircle2 size={22} /> Mnemonic Recovered
</span>
<button
onClick={() => setShowMnemonic(!showMnemonic)}
onClick={() => setDecryptedRestoredMnemonic(null)}
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>
</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 ${
isReadOnly ? 'blur-md select-none' : ''
}`}>
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
{restoredData.w}
{decryptedRestoredMnemonic}
</p>
</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>
{/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500">
<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>
</div>
</div>
</main>
<Footer
appVersion={__APP_VERSION__}
buildHash={__BUILD_HASH__}
buildTimestamp={__BUILD_TIMESTAMP__}
/>
{/* QR Scanner Modal */}
{showQRScanner && (
@@ -428,9 +700,105 @@ function App() {
onClose={() => setShowQRScanner(false)}
/>
)}
</>
);
{/* Security Modal */}
{showSecurityModal && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
onClick={() => setShowSecurityModal(false)}
>
<div
className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-white mb-4">Security Limitations</h3>
<div className="text-sm text-slate-300 space-y-2">
<SecurityWarnings />
</div>
</div>
</div>
)}
{/* Storage Modal */}
{showStorageModal && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
onClick={() => setShowStorageModal(false)}
>
<div
className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-white mb-4">Storage Details</h3>
<div className="text-sm text-slate-300 space-y-2">
<StorageDetails localItems={localItems} sessionItems={sessionItems} />
</div>
</div>
</div>
)}
{/* Clipboard Modal */}
{showClipboardModal && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
onClick={() => setShowClipboardModal(false)}
>
<div
className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-white mb-4">Clipboard Activity</h3>
<div className="text-sm text-slate-300 space-y-2">
<ClipboardDetails events={clipboardEvents} onClear={clearClipboard} />
</div>
</div>
</div>
)}
{/* Lock Confirmation Modal */}
{showLockConfirm && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
onClick={() => setShowLockConfirm(false)}
>
<div
className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Lock className="w-5 h-5 text-amber-500" />
Lock Sensitive Data?
</h3>
<div className="text-sm text-slate-300 space-y-3 mb-6">
<p>This will:</p>
<ul className="list-disc list-inside space-y-1 text-slate-400">
<li>Blur all sensitive data (mnemonics, keys, passwords)</li>
<li>Disable all inputs</li>
<li>Prevent clipboard operations</li>
</ul>
<p className="text-xs text-slate-500 mt-2">
Use this when showing the app to others or stepping away from your device.
</p>
</div>
<div className="flex gap-3">
<button
className="flex-1 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg transition-colors"
onClick={() => setShowLockConfirm(false)}
>
Cancel
</button>
<button
className="flex-1 py-2 bg-amber-500 hover:bg-amber-600 text-white font-semibold rounded-lg transition-colors"
onClick={confirmLock}
>
Lock Data
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default App;

View File

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

18
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react';
interface FooterProps {
appVersion: string;
buildHash: string;
buildTimestamp: string;
}
const Footer: React.FC<FooterProps> = ({ appVersion, buildHash, buildTimestamp }) => {
return (
<footer className="text-center text-xs text-slate-400 p-4">
<p>SeedPGP v{appVersion} build {buildHash} {buildTimestamp}</p>
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
</footer>
);
};
export default Footer;

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

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

View File

@@ -1,15 +1,14 @@
import React, { useState } from 'react';
import { Upload } from 'lucide-react';
import type { LucideIcon } from "lucide-react";
interface PgpKeyInputProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
label: string;
icon?: LucideIcon;
readOnly?: boolean;
}
export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
@@ -17,21 +16,25 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
onChange,
placeholder,
label,
icon: Icon
icon: Icon,
readOnly = false,
}) => {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(false);
@@ -49,30 +52,34 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
return (
<div className="space-y-2">
<label className="text-sm font-semibold text-slate-700 flex items-center justify-between">
<label className="text-sm font-semibold text-slate-200 flex items-center justify-between">
<span className="flex items-center gap-2">
{Icon && <Icon size={14} />} {label}
</span>
{!readOnly && (
<span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
Drag & Drop .asc file
</span>
)}
</label>
<div
className={`relative transition-all duration-200 ${isDragging ? 'scale-[1.01]' : ''}`}
className={`relative transition-all duration-200 ${isDragging && !readOnly ? 'scale-[1.01]' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<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-teal-500 ${isDragging && !readOnly ? 'border-teal-500 bg-teal-50' : 'border-slate-200'} ${
readOnly ? 'blur-sm select-none' : ''
}`}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
readOnly={readOnly}
/>
{isDragging && (
<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">
{isDragging && !readOnly && (
<div className="absolute inset-0 flex items-center justify-center bg-teal-50/90 rounded-xl border-2 border-dashed border-teal-500 pointer-events-none z-10">
<div className="text-teal-600 font-bold flex flex-col items-center animate-bounce">
<Upload size={24} />
<span className="text-sm mt-2">Drop Key File Here</span>
</div>

View File

@@ -159,7 +159,7 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
<div className="space-y-3">
<button
onClick={startCamera}
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg"
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg"
>
<Camera size={20} />
Use Camera
@@ -184,7 +184,7 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
{/* Info Box */}
<div className="pt-4 border-t border-slate-200">
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
<Info size={14} className="shrink-0 mt-0.5 text-blue-600" />
<Info size={14} className="shrink-0 mt-0.5 text-teal-600" />
<div>
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
@@ -210,7 +210,7 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
{/* File Processing View */}
{scanMode === 'file' && scanning && (
<div className="py-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-blue-600"></div>
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-teal-600"></div>
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
</div>
)}

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-teal-600 focus:ring-2 focus:ring-teal-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>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
export const SecurityWarnings: React.FC = () => {
return (
<div className="space-y-3">
<Warning
icon="🧵"
title="JavaScript Strings are Immutable"
description="Strings cannot be overwritten in memory. Copies persist until garbage collection runs (timing unpredictable)."
/>
<Warning
icon="🗑️"
title="No Guaranteed Memory Wiping"
description="JavaScript has no secure memory clearing. Sensitive data may linger in RAM until GC or browser restart."
/>
<Warning
icon="📋"
title="Clipboard Exposure"
description="Copied data is accessible to other tabs/apps. Browser extensions can read clipboard contents."
/>
<Warning
icon="💾"
title="Browser Storage Persistence"
description="localStorage survives browser restart. sessionStorage survives page refresh. Both readable by any script on this domain."
/>
<Warning
icon="🔍"
title="DevTools Access"
description="All app state, memory, and storage visible in browser DevTools. Never use on untrusted devices."
/>
<Warning
icon="🌐"
title="Network Risks (When Online)"
description="If hosted online: DNS, HTTPS, CDN, and browser can see usage patterns. Use offline/local for maximum security."
/>
<div className="pt-3 border-t border-slate-600 text-xs text-slate-400">
<strong className="text-slate-300">Recommendation:</strong>{' '}
Use this tool on a dedicated offline device. Clear browser data after each use. Never use on shared/public computers.
</div>
</div>
);
};
const Warning = ({
icon,
title,
description,
}: {
icon: string;
title: string;
description: string;
}) => (
<div className="flex gap-2 text-sm">
<span className="text-lg flex-shrink-0">{icon}</span>
<div>
<div className="font-semibold text-slate-200 mb-1">{title}</div>
<div className="text-slate-400 leading-relaxed">{description}</div>
</div>
</div>
);

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Lock, Unlock } from 'lucide-react';
interface EditLockBadgeProps {
isLocked: boolean;
onToggle: () => void;
}
const EditLockBadge: React.FC<EditLockBadgeProps> = ({ isLocked, onToggle }) => {
return (
<button
onClick={onToggle}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all hover:scale-105 ${
isLocked
? 'text-amber-500 bg-amber-500/10 border-amber-500/30 font-semibold'
: 'text-green-500 bg-green-500/10 border-green-500/30'
}`}
title={isLocked ? 'Click to unlock and edit' : 'Click to lock and blur sensitive data'}
>
{isLocked ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />}
<span className="text-xs font-medium">
{isLocked ? 'Locked' : 'Edit'}
</span>
</button>
);
};
export default EditLockBadge;

View File

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

View File

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

189
src/lib/krux.test.ts Normal file
View File

@@ -0,0 +1,189 @@
// Krux KEF tests using Bun test runner
import { describe, test, expect } from "bun:test";
import {
encryptToKrux,
decryptFromKrux,
hexToBytes,
bytesToHex,
wrap,
unwrap,
KruxCipher
} from './krux';
describe('Krux KEF Implementation', () => {
// Test basic hex conversion
test('hexToBytes and bytesToHex roundtrip', () => {
const original = 'Hello, World!';
const bytes = new TextEncoder().encode(original);
const hex = bytesToHex(bytes);
const back = hexToBytes(hex);
expect(new TextDecoder().decode(back)).toBe(original);
});
test('hexToBytes handles KEF: prefix', () => {
const hex = '48656C6C6F';
const withPrefix = `KEF:${hex}`;
const bytes1 = hexToBytes(hex);
const bytes2 = hexToBytes(withPrefix);
expect(bytes2).toEqual(bytes1);
});
test('hexToBytes rejects invalid hex', () => {
expect(() => hexToBytes('12345')).toThrow('Hex string must have even length');
expect(() => hexToBytes('12345G')).toThrow('Invalid hex string');
});
// Test wrap/unwrap
test('wrap and unwrap roundtrip', () => {
const label = 'Test Label';
const version = 20;
const iterations = 200000;
const payload = new TextEncoder().encode('test payload');
const wrapped = wrap(label, version, iterations, payload);
const unwrapped = unwrap(wrapped);
expect(unwrapped.label).toBe(label);
expect(unwrapped.version).toBe(version);
expect(unwrapped.iterations).toBe(iterations);
expect(unwrapped.payload).toEqual(payload);
});
test('wrap rejects label too long', () => {
const longLabel = 'a'.repeat(253); // 253 > 252 max
const payload = new Uint8Array([1, 2, 3]);
expect(() => wrap(longLabel, 20, 10000, payload))
.toThrow('Label too long');
});
test('wrap accepts empty label', () => {
const payload = new Uint8Array([1, 2, 3]);
const wrapped = wrap('', 20, 10000, payload);
const unwrapped = unwrap(wrapped);
expect(unwrapped.label).toBe('');
expect(unwrapped.version).toBe(20);
expect(unwrapped.iterations).toBe(10000);
expect(unwrapped.payload).toEqual(payload);
});
test('unwrap rejects invalid envelope', () => {
expect(() => unwrap(new Uint8Array([1, 2, 3]))).toThrow('Invalid KEF envelope: too short');
// Label length too large (253 > 252)
expect(() => unwrap(new Uint8Array([253, 20, 0, 0, 100]))).toThrow('Invalid label length');
// Empty label (lenId=0) is valid, but need enough data for version+iterations
// Create a valid envelope with empty label: [0, version, iter1, iter2, iter3, payload...]
const emptyLabelEnvelope = new Uint8Array([0, 20, 0, 0, 100, 1, 2, 3]);
const unwrapped = unwrap(emptyLabelEnvelope);
expect(unwrapped.label).toBe('');
expect(unwrapped.version).toBe(20);
});
// Test encryption/decryption
test('encryptToKrux and decryptFromKrux roundtrip', async () => {
const mnemonic = 'test test test test test test test test test test test junk';
const passphrase = 'secure-passphrase';
const label = 'Test Seed';
const iterations = 10000;
const encrypted = await encryptToKrux({
mnemonic,
passphrase,
label,
iterations,
version: 20,
});
expect(encrypted.kefHex).toMatch(/^[0-9A-F]+$/);
expect(encrypted.label).toBe(label);
expect(encrypted.iterations).toBe(iterations);
expect(encrypted.version).toBe(20);
const decrypted = await decryptFromKrux({
kefHex: encrypted.kefHex,
passphrase,
});
expect(decrypted.mnemonic).toBe(mnemonic);
expect(decrypted.label).toBe(label);
expect(decrypted.iterations).toBe(iterations);
expect(decrypted.version).toBe(20);
});
test('encryptToKrux requires passphrase', async () => {
await expect(encryptToKrux({
mnemonic: 'test',
passphrase: '',
})).rejects.toThrow('Passphrase is required');
});
test('decryptFromKrux requires passphrase', async () => {
await expect(decryptFromKrux({
kefHex: '123456',
passphrase: '',
})).rejects.toThrow('Passphrase is required');
});
test('wrong passphrase fails decryption', async () => {
const mnemonic = 'test mnemonic';
const passphrase = 'correct-passphrase';
const encrypted = await encryptToKrux({
mnemonic,
passphrase,
});
await expect(decryptFromKrux({
kefHex: encrypted.kefHex,
passphrase: 'wrong-passphrase',
})).rejects.toThrow(/Krux decryption failed/);
});
// Test KruxCipher class directly
test('KruxCipher encrypt/decrypt roundtrip', async () => {
const cipher = new KruxCipher('passphrase', 'salt', 10000);
const plaintext = new TextEncoder().encode('secret message');
const encrypted = await cipher.encrypt(plaintext);
const decrypted = await cipher.decrypt(encrypted, 20);
expect(new TextDecoder().decode(decrypted)).toBe('secret message');
});
test('KruxCipher rejects unsupported version', async () => {
const cipher = new KruxCipher('passphrase', 'salt', 10000);
const plaintext = new Uint8Array([1, 2, 3]);
await expect(cipher.encrypt(plaintext, 99)).rejects.toThrow('Unsupported KEF version');
await expect(cipher.decrypt(new Uint8Array(50), 99)).rejects.toThrow('Unsupported KEF version');
});
test('KruxCipher rejects short payload', async () => {
const cipher = new KruxCipher('passphrase', 'salt', 10000);
// Version 20: IV (12) + auth (4) = 16 bytes minimum
const shortPayload = new Uint8Array(15); // Too short for IV + GCM tag (needs at least 16)
await expect(cipher.decrypt(shortPayload, 20)).rejects.toThrow('Payload too short for AES-GCM');
});
test('iterations scaling works correctly', () => {
// Test that iterations are scaled properly when divisible by 10000
const label = 'Test';
const version = 20;
const payload = new Uint8Array([1, 2, 3]);
// 200000 should be scaled to 20 in the envelope
const wrapped1 = wrap(label, version, 200000, payload);
expect(wrapped1[6]).toBe(0); // 200000 / 10000 = 20
expect(wrapped1[7]).toBe(0);
expect(wrapped1[8]).toBe(20);
// 10001 should not be scaled
const wrapped2 = wrap(label, version, 10001, payload);
const iterStart = 2 + label.length;
const iters = (wrapped2[iterStart] << 16) | (wrapped2[iterStart + 1] << 8) | wrapped2[iterStart + 2];
expect(iters).toBe(10001);
});
});

331
src/lib/krux.ts Normal file
View File

@@ -0,0 +1,331 @@
// src/lib/krux.ts
// Krux KEF (Krux Encryption Format) implementation
// Compatible with Krux firmware (AES-GCM, label as salt, hex QR)
// Currently implements version 20 (AES-GCM without compression)
// Version 21 (AES-GCM +c) support can be added later with compression
// KEF version definitions (matches Python reference)
export const VERSIONS: Record<number, {
name: string;
compress?: boolean;
auth: number; // GCM tag length (4 for version 20, full 16 for v1?)
}> = {
20: { name: "AES-GCM", auth: 4 },
// Version 21 would be: { name: "AES-GCM +c", compress: true, auth: 4 }
};
// IV length for GCM mode
const GCM_IV_LENGTH = 12;
/**
* Convert data to a proper ArrayBuffer for Web Crypto API.
* Ensures it's not a SharedArrayBuffer.
*/
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
// Always create a new ArrayBuffer and copy the data
const buffer = new ArrayBuffer(data.length);
new Uint8Array(buffer).set(data);
return buffer;
}
/**
* Wrap data into KEF envelope format (matches Python exactly)
* Format: [1 byte label length][label bytes][1 byte version][3 bytes iterations][payload]
*/
export function wrap(label: string, version: number, iterations: number, payload: Uint8Array): Uint8Array {
const labelBytes = new TextEncoder().encode(label);
if (!(0 <= labelBytes.length && labelBytes.length <= 252)) {
throw new Error("Label too long (max 252 bytes)");
}
const lenId = new Uint8Array([labelBytes.length]);
const versionByte = new Uint8Array([version]);
let itersBytes = new Uint8Array(3);
// Krux firmware expects iterations in multiples of 10000 when possible
if (iterations % 10000 === 0) {
const scaled = iterations / 10000;
if (!(1 <= scaled && scaled <= 10000)) {
throw new Error("Iterations out of scaled range");
}
itersBytes[0] = (scaled >> 16) & 0xff;
itersBytes[1] = (scaled >> 8) & 0xff;
itersBytes[2] = scaled & 0xff;
} else {
if (!(10000 < iterations && iterations < 2**24)) {
throw new Error("Iterations out of range");
}
itersBytes[0] = (iterations >> 16) & 0xff;
itersBytes[1] = (iterations >> 8) & 0xff;
itersBytes[2] = iterations & 0xff;
}
return new Uint8Array([...lenId, ...labelBytes, ...versionByte, ...itersBytes, ...payload]);
}
/**
* Unwrap KEF envelope to extract components (matches Python exactly)
*/
export function unwrap(envelope: Uint8Array): {
label: string;
version: number;
iterations: number;
payload: Uint8Array
} {
if (envelope.length < 5) {
throw new Error("Invalid KEF envelope: too short");
}
const lenId = envelope[0];
if (!(0 <= lenId && lenId <= 252)) {
throw new Error("Invalid label length in KEF envelope");
}
if (1 + lenId + 4 > envelope.length) {
throw new Error("Invalid KEF envelope: insufficient data");
}
const labelBytes = envelope.subarray(1, 1 + lenId);
const label = new TextDecoder().decode(labelBytes);
const version = envelope[1 + lenId];
const iterStart = 2 + lenId;
let iters = (envelope[iterStart] << 16) | (envelope[iterStart + 1] << 8) | envelope[iterStart + 2];
const iterations = iters <= 10000 ? iters * 10000 : iters;
const payload = envelope.subarray(5 + lenId);
return { label, version, iterations, payload };
}
/**
* Krux Cipher class for AES-GCM encryption/decryption
*/
export class KruxCipher {
private keyPromise: Promise<CryptoKey>;
constructor(passphrase: string, salt: string, iterations: number) {
const encoder = new TextEncoder();
this.keyPromise = (async () => {
// Import passphrase as raw key material
const passphraseBytes = encoder.encode(passphrase);
const passphraseBuffer = toArrayBuffer(passphraseBytes);
const baseKey = await crypto.subtle.importKey(
"raw",
passphraseBuffer,
{ name: "PBKDF2" },
false,
["deriveKey"]
);
// Derive AES-GCM key using PBKDF2
const saltBytes = encoder.encode(salt);
const saltBuffer = toArrayBuffer(saltBytes);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: saltBuffer,
iterations: Math.max(1, iterations),
hash: "SHA-256"
},
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
})();
}
/**
* Encrypt plaintext using AES-GCM
*/
async encrypt(plaintext: Uint8Array, version = 20, iv?: Uint8Array): Promise<Uint8Array> {
const v = VERSIONS[version];
if (!v) {
throw new Error(`Unsupported KEF version: ${version}`);
}
// Note: No compression for version 20
// For version 21, we would add compression here
// Ensure ivBytes is a fresh Uint8Array with its own ArrayBuffer (not SharedArrayBuffer)
let ivBytes: Uint8Array;
if (iv) {
// Copy the iv to ensure we have our own buffer
ivBytes = new Uint8Array(iv.length);
ivBytes.set(iv);
} else {
// Create new random IV with a proper ArrayBuffer
ivBytes = new Uint8Array(GCM_IV_LENGTH);
crypto.getRandomValues(ivBytes);
}
const key = await this.keyPromise;
const plaintextBuffer = toArrayBuffer(plaintext);
const ivBuffer = toArrayBuffer(ivBytes);
// Use auth length from version definition (in bytes, convert to bits)
const tagLengthBits = v.auth * 8;
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: ivBuffer,
tagLength: tagLengthBits
},
key,
plaintextBuffer
);
// For GCM, encrypted result includes ciphertext + tag
// Separate ciphertext and tag
const authBytes = v.auth;
const encryptedBytes = new Uint8Array(encrypted);
const ciphertext = encryptedBytes.slice(0, encryptedBytes.length - authBytes);
const tag = encryptedBytes.slice(encryptedBytes.length - authBytes);
// Combine IV + ciphertext + tag (matches Python format)
const combined = new Uint8Array(ivBytes.length + ciphertext.length + tag.length);
combined.set(ivBytes, 0);
combined.set(ciphertext, ivBytes.length);
combined.set(tag, ivBytes.length + ciphertext.length);
return combined;
}
/**
* Decrypt payload using AES-GCM
*/
async decrypt(payload: Uint8Array, version: number): Promise<Uint8Array> {
const v = VERSIONS[version];
if (!v) {
throw new Error(`Unsupported KEF version: ${version}`);
}
const ivLen = GCM_IV_LENGTH;
const authBytes = v.auth;
// Payload is IV + ciphertext + tag
if (payload.length < ivLen + authBytes) {
throw new Error("Payload too short for AES-GCM");
}
// Extract IV, ciphertext, and tag
const iv = payload.slice(0, ivLen);
const ciphertext = payload.slice(ivLen, payload.length - authBytes);
const tag = payload.slice(payload.length - authBytes);
const key = await this.keyPromise;
try {
// For Web Crypto, we need to combine ciphertext + tag
const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
ciphertextWithTag.set(ciphertext, 0);
ciphertextWithTag.set(tag, ciphertext.length);
const ciphertextBuffer = toArrayBuffer(ciphertextWithTag);
const ivBuffer = toArrayBuffer(iv);
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: ivBuffer,
tagLength: authBytes * 8
},
key,
ciphertextBuffer
);
return new Uint8Array(decrypted);
} catch (error) {
// Web Crypto throws generic errors for decryption failure
// Convert to user-friendly message
throw new Error("Krux decryption failed - wrong passphrase or corrupted data");
}
}
}
/**
* Convert hex string to bytes
*/
export function hexToBytes(hex: string): Uint8Array {
// Remove any whitespace and optional KEF: prefix
const cleaned = hex.trim().replace(/\s/g, '').replace(/^KEF:/i, '');
if (!/^[0-9a-fA-F]+$/.test(cleaned)) {
throw new Error("Invalid hex string");
}
if (cleaned.length % 2 !== 0) {
throw new Error("Hex string must have even length");
}
const bytes = new Uint8Array(cleaned.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(cleaned.substr(i * 2, 2), 16);
}
return bytes;
}
/**
* Convert bytes to hex string
*/
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
}
/**
* Encrypt mnemonic to KEF format
*/
export async function encryptToKrux(params: {
mnemonic: string;
passphrase: string;
label?: string;
iterations?: number;
version?: number;
}): Promise<{ kefHex: string; label: string; version: number; iterations: number }> {
const label = params.label || "Seed Backup";
const iterations = params.iterations || 200000;
const version = params.version || 20;
if (!params.passphrase) {
throw new Error("Passphrase is required for Krux encryption");
}
const mnemonicBytes = new TextEncoder().encode(params.mnemonic);
const cipher = new KruxCipher(params.passphrase, label, iterations);
const payload = await cipher.encrypt(mnemonicBytes, version);
const kef = wrap(label, version, iterations, payload);
return {
kefHex: bytesToHex(kef),
label,
version,
iterations
};
}
/**
* Decrypt KEF hex to mnemonic
*/
export async function decryptFromKrux(params: {
kefHex: string;
passphrase: string;
}): Promise<{ mnemonic: string; label: string; version: number; iterations: number }> {
if (!params.passphrase) {
throw new Error("Passphrase is required for Krux decryption");
}
const bytes = hexToBytes(params.kefHex);
const { label, version, iterations, payload } = unwrap(bytes);
const cipher = new KruxCipher(params.passphrase, label, iterations);
const decrypted = await cipher.decrypt(payload, version);
const mnemonic = new TextDecoder().decode(decrypted);
return { mnemonic, label, version, iterations };
}

View File

@@ -1,7 +1,21 @@
import * as openpgp from "openpgp";
import { base45Encode, base45Decode } from "./base45";
import { crc16CcittFalse } from "./crc16";
import type { SeedPgpPlaintext, ParsedSeedPgpFrame } from "./types";
import { encryptToKrux, decryptFromKrux, hexToBytes } from "./krux";
import type {
SeedPgpPlaintext,
ParsedSeedPgpFrame,
EncryptionMode,
EncryptionParams,
DecryptionParams,
EncryptionResult
} from "./types";
// Configure OpenPGP.js (disable warnings)
openpgp.config.showComment = false;
openpgp.config.showVersion = false;
openpgp.config.allowUnauthenticatedMessages = true; // Suppress AES warning
openpgp.config.allowUnauthenticatedStream = true; // Suppress stream warning
function nonEmptyTrimmed(s?: string): string | undefined {
if (!s) return undefined;
@@ -188,3 +202,135 @@ export async function decryptSeedPgp(params: {
return obj;
}
/**
* Unified encryption function supporting both PGP and Krux modes
*/
export async function encryptToSeed(params: EncryptionParams): Promise<EncryptionResult> {
const mode = params.mode || 'pgp';
if (mode === 'krux') {
const plaintextStr = typeof params.plaintext === 'string'
? params.plaintext
: params.plaintext.w;
const passphrase = params.messagePassword || '';
if (!passphrase) {
throw new Error("Krux mode requires a message password (passphrase)");
}
try {
const result = await encryptToKrux({
mnemonic: plaintextStr,
passphrase,
label: params.kruxLabel,
iterations: params.kruxIterations,
version: params.kruxVersion,
});
return {
framed: result.kefHex,
label: result.label,
version: result.version,
iterations: result.iterations,
};
} catch (error) {
if (error instanceof Error) {
throw new Error(`Krux encryption failed: ${error.message}`);
}
throw error;
}
}
// Default to PGP mode
const plaintextObj = typeof params.plaintext === 'string'
? buildPlaintext(params.plaintext, false)
: params.plaintext;
const result = await encryptToSeedPgp({
plaintext: plaintextObj,
publicKeyArmored: params.publicKeyArmored,
messagePassword: params.messagePassword,
});
return {
framed: result.framed,
pgpBytes: result.pgpBytes,
recipientFingerprint: result.recipientFingerprint,
};
}
/**
* Unified decryption function supporting both PGP and Krux modes
*/
export async function decryptFromSeed(params: DecryptionParams): Promise<SeedPgpPlaintext> {
const mode = params.mode || 'pgp';
if (mode === 'krux') {
const passphrase = params.messagePassword || '';
if (!passphrase) {
throw new Error("Krux mode requires a message password (passphrase)");
}
try {
const result = await decryptFromKrux({
kefHex: params.frameText,
passphrase,
});
// Convert to SeedPgpPlaintext format for consistency
return {
v: 1,
t: "bip39",
w: result.mnemonic,
l: "en",
pp: 0,
};
} catch (error) {
if (error instanceof Error) {
throw new Error(`Krux decryption failed: ${error.message}`);
}
throw error;
}
}
// Default to PGP mode
return decryptSeedPgp({
frameText: params.frameText,
privateKeyArmored: params.privateKeyArmored,
privateKeyPassphrase: params.privateKeyPassphrase,
messagePassword: params.messagePassword,
});
}
/**
* Detect encryption mode from input text
*/
export function detectEncryptionMode(text: string): EncryptionMode {
const trimmed = text.trim();
// Check for SEEDPGP1 format
if (trimmed.startsWith('SEEDPGP1:')) {
return 'pgp';
}
// Check for hex format (Krux KEF)
const cleaned = trimmed.replace(/\s/g, '').replace(/^KEF:/i, '');
if (/^[0-9a-fA-F]+$/.test(cleaned) && cleaned.length % 2 === 0) {
// Try to parse as KEF to confirm
try {
const bytes = hexToBytes(cleaned);
if (bytes.length >= 5) {
const lenId = bytes[0];
if (lenId > 0 && lenId <= 252 && 1 + lenId + 4 <= bytes.length) {
return 'krux';
}
}
} catch {
// Not valid KEF, fall through
}
}
// Default to PGP for backward compatibility
return 'pgp';
}

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

@@ -12,3 +12,39 @@ export type ParsedSeedPgpFrame = {
crc16: string;
b45: string;
};
// Krux KEF types
export type KruxEncryptionParams = {
label?: string;
iterations?: number;
version?: number;
};
export type EncryptionMode = 'pgp' | 'krux';
export type EncryptionParams = {
plaintext: SeedPgpPlaintext | string;
publicKeyArmored?: string;
messagePassword?: string;
mode?: EncryptionMode;
kruxLabel?: string;
kruxIterations?: number;
kruxVersion?: number;
};
export type DecryptionParams = {
frameText: string;
privateKeyArmored?: string;
privateKeyPassphrase?: string;
messagePassword?: string;
mode?: EncryptionMode;
};
export type EncryptionResult = {
framed: string;
pgpBytes?: Uint8Array;
recipientFingerprint?: string;
label?: string;
version?: number;
iterations?: number;
};

View File

@@ -1,8 +1,32 @@
// Suppress OpenPGP.js AES cipher warnings
const originalWarn = console.warn;
const originalError = console.error;
console.warn = (...args: any[]) => {
const msg = args[0]?.toString() || '';
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
return;
}
originalWarn.apply(console, args);
};
console.error = (...args: any[]) => {
const msg = args[0]?.toString() || '';
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
return;
}
originalError.apply(console, args);
};
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
if (import.meta.env.DEV) {
await import('./lib/sessionCrypto');
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />

3
src/vite-env.d.ts vendored
View File

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

BIN
test.pgp

Binary file not shown.

View File

@@ -1,18 +1,34 @@
import { defineConfig } from 'vite'
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({
plugins: [react()],
base: '/seedpgp-web-app/', // Match your repo name
plugins: [
react(),
{
name: 'html-transform',
transformIndexHtml(html) {
return html.replace(/__APP_VERSION__/g, appVersion);
}
}
],
base: '/', // Always use root, since we're Cloudflare Pages only
publicDir: 'public', // ← Explicitly set (should be default)
build: {
chunkSizeWarningLimit: 600, // Suppress warning
rollupOptions: {
output: {
manualChunks: {
'openpgp': ['openpgp'], // Separate chunk for PGP
}
}
}
outDir: 'dist',
emptyOutDir: false,
},
define: {
'__APP_VERSION__': JSON.stringify(appVersion),
'__BUILD_HASH__': JSON.stringify(gitHash),
'__BUILD_TIMESTAMP__': JSON.stringify(new Date().toISOString()),
}
})