11 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
30 changed files with 2598 additions and 1078 deletions

View File

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

View File

@@ -2,10 +2,10 @@
## Project Overview ## Project Overview
**SeedPGP v1.3.0**: Client-side BIP39 mnemonic encryption webapp **SeedPGP v1.4.4**: Client-side BIP39 mnemonic encryption webapp
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS **Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
**Deploy**: GitHub Pages (public repo: `seedpgp-web-app`, private source: `seedpgp-web`) **Deploy**: Cloudflare Pages (private repo: `seedpgp-web`)
**Live URL**: <https://kccleoc.github.io/seedpgp-web-app/> **Live URL**: <https://seedpgp-web.pages.dev/>
## Core Constraints ## Core Constraints
@@ -130,18 +130,23 @@ bun run preview # Preview production build
### Deployment Process ### Deployment Process
This project is now deployed to Cloudflare Pages for enhanced security. **Production:** Cloudflare Pages (auto-deploys from `main` branch)
**Live URL:** <https://seedpgp-web.pages.dev>
1. **Private repo** (`seedpgp-web`): Source code, development ### Cloudflare Pages Setup
2. **Cloudflare Pages**: Deploys from `seedpgp-web` repo directly.
3. **GitHub Pages (Legacy)**: `seedpgp-web-app` public repo is retained for historical purposes, but no longer actively deployed to.
### Cloudflare Pages Deployment 1. **Repository:** `seedpgp-web` (private repo)
2. **Build command:** `bun run build`
3. **Output directory:** `dist/`
4. **Security headers:** Automatically enforced via `public/_headers`
1. Connect GitHub repo (`seedpgp-web`) to Cloudflare Pages. ### Benefits Over GitHub Pages
2. Build settings: `bun run build`, output directory: `dist/`.
3. `public/_headers` file enforces Content Security Policy (CSP) and other security headers automatically. - ✅ Real CSP header enforcement (blocks network requests at browser level)
4. Benefits: Real CSP enforcement, not just a UI toggle. - ✅ 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 ### Git Workflow
@@ -150,10 +155,19 @@ This project is now deployed to Cloudflare Pages for enhanced security.
git add src/ git add src/
git commit -m "feat(v1.x): description" git commit -m "feat(v1.x): description"
# Tag version # Tag version (triggers auto-deploy to Cloudflare)
git tag v1.x.x git tag v1.x.x
git push origin main --tags 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 # Deploy to GitHub Pages
./scripts/deploy.sh v1.x.x ./scripts/deploy.sh v1.x.x
``` ```
@@ -300,26 +314,26 @@ await window.runSessionCryptoTest()
--- ---
## Current Version: v1.4.0 ## Current Version: v1.4.4
### Recent Changes (2026-01-30) **Recent Changes (v1.4.4):**
- ✅ Extended session-key encryption to Restore flow - Enhanced security documentation with explicit threat model
- ✅ Added 10-second auto-clear timer for restored mnemonic - Improved README with simple examples and best practices
- ✅ Added Hide button for manual clear - Better air-gapped usage guidance for maximum security
- ✅ Removed debug console logs from sessionCrypto.ts - Version bump with security audit improvements
### Known Limitations **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
- GitHub Pages cannot set custom CSP headers (need Cloudflare Pages for enforcement) **Next Priorities:**
- Read-only Mode is UI-level only (not browser-enforced) 1. Enhanced BIP39 validation (full wordlist + checksum)
- Session-key encryption doesn't protect against active XSS/extensions 2. Multi-frame support for larger payloads
3. Hardware wallet integration (Trezor/Keystone)
### Next Priorities (Suggested)
1. Extend session-key encryption to Restore flow
2. Migrate to Cloudflare Pages for real CSP header enforcement
3. Add "Encrypted in memory" badge when encryptedMnemonicCache exists
4. Document reproducible builds (git hash verification)
--- ---
@@ -379,7 +393,6 @@ Check:
Output: ✅ or ❌ for each item + suggest fixes for failures. Output: ✅ or ❌ for each item + suggest fixes for failures.
``` ```
--- ---
**Last Updated**: 2026-01-29 **Last Updated**: 2026-01-29

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** **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 ```bash
# Clone repository
git clone https://github.com/kccleoc/seedpgp-web.git git clone https://github.com/kccleoc/seedpgp-web.git
cd seedpgp-web 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 bun install
# Run tests # Run tests
@@ -28,339 +206,253 @@ bun test
# Start development server # Start development server
bun run dev bun run dev
# Open http://localhost:5173
``` ```
## Usage ### Production Build
### 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
```bash ```bash
# Build the production bundle bun run build # Build to dist/
bun run build bun run preview # Preview production 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
``` ```
--- ---
### Repository Structure ## 🔐 Advanced Security Features
- **seedpgp-web** (Private) - Your source code, active development ### Session-Key Encryption
- **seedpgp-web-app** (Public) - Built files only, served via GitHub Pages - **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
``` ### Read-Only Mode
SEEDPGP1:FRAME:CRC16:BASE45DATA - 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 ```typescript
{ interface EncryptionParams {
plaintext: SeedPgpPlaintext; plaintext: string | SeedPgpPlaintext; // Mnemonic or plaintext object
publicKeyArmored?: string; // PGP public key (PKESK) publicKeyArmored?: string; // PGP public key (optional)
messagePassword?: string; // Symmetric password (SKESK) 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:** #### `decryptFromSeed(params)`
```typescript
{
framed: string; // SEEDPGP1 frame
pgpBytes: Uint8Array; // Raw PGP message
recipientFingerprint?: string; // Key fingerprint
}
```
### `decryptSeedPgp(params)`
Decrypts a SeedPGP frame. Decrypts a SeedPGP frame.
**Parameters:**
```typescript ```typescript
{ interface DecryptionParams {
frameText: string; // SEEDPGP1 frame frameText: string; // SEEDPGP1 frame or KEF hex
privateKeyArmored?: string; // PGP private key privateKeyArmored?: string; // PGP private key (optional)
privateKeyPassphrase?: string; // Key unlock password privateKeyPassphrase?: string; // Key password (optional)
messagePassword?: string; // SKESK password 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 ```bash
# Run all tests # Run all tests
bun test bun test
# Run with verbose output # Run specific test categories
bun test --verbose 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 bun test --watch
``` ```
### Test Coverage ### 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 ## 📁 Project Structure
### ✅ 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
``` ```
seedpgp-web/ seedpgp-web/
├── src/ ├── 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/ │ ├── lib/
│ │ ├── seedpgp.ts # Core encryption/decryption │ │ ├── seedpgp.ts # Core encryption/decryption
│ │ ├── seedpgp.test.ts # Test vectors │ │ ├── sessionCrypto.ts # AES-GCM session key management
│ │ ├── base45.ts # Base45 codec │ │ ├── krux.ts # Krux KEF compatibility
│ │ ├── crc16.ts # CRC16-CCITT-FALSE │ │ ├── bip39.ts # BIP39 validation
│ │ ── types.ts # TypeScript definitions │ │ ── base45.ts # Base45 encoding/decoding
│ └── App.tsx # React UI │ └── crc16.ts # CRC16-CCITT-FALSE checksums
├── scripts/ │ ├── App.tsx # Main application
│ └── deploy.sh # Deployment automation │ └── main.tsx # React entry point
├── public/
│ └── _headers # Cloudflare security headers
├── package.json ├── package.json
├── DEVELOPMENT.md # Development guide ├── vite.config.ts
├── RECOVERY_PLAYBOOK.md # Offline recovery guide
└── README.md # This file └── README.md # This file
``` ```
## Tech Stack ---
- **Runtime**: [Bun](https://bun.sh) v1.3.6+ ## 🔄 Version History
- **Language**: TypeScript
- **Crypto**: [OpenPGP.js](https://openpgpjs.org) v6.3.0
- **Framework**: React + Vite
- **Testing**: Bun test runner
## 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 ### v1.4.3 (2026-01-30)
- [ ] QR code scanner with camera support - ✅ Fixed textarea contrast for readability
- [ ] Multi-frame support for larger payloads - ✅ Fixed overlapping floating boxes
- [ ] Hardware wallet integration - ✅ Polished UI with modern crypto wallet design
- [ ] Mobile scanning app
- [ ] Shamir Secret Sharing support
## 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 [View full version history...](https://github.com/kccleoc/seedpgp-web/releases)
**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
--- ---
⚠️ **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 ### Medium-term
mkdir -p scripts - [ ] Shamir Secret Sharing support
cat > scripts/deploy.sh << 'EOF' - [ ] Mobile companion app (React Native)
#!/bin/bash - [ ] Printable paper backup templates
set -e - [ ] 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..." ## ⚖️ License
bun run build
echo "📦 Deploying to GitHub Pages..." MIT License - see [LICENSE](LICENSE) file for details.
cd dist
git add .
git commit -m "Deploy $VERSION" || echo "No changes to commit"
git push
cd .. ## 👤 Author
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
echo "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
EOF
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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SeedPGP v1.4</title> <title>SeedPGP v__APP_VERSION__</title>
</head> </head>
<body> <body>

View File

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

View File

@@ -1,5 +1,5 @@
/* /*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; form-action 'none'; base-uri 'self'; 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-Frame-Options: DENY
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block X-XSS-Protection: 1; mode=block

View File

@@ -1,35 +0,0 @@
#!/bin/bash
set -e
VERSION=$1
if [ -z "$VERSION" ]; then
echo "Usage: ./scripts/deploy.sh v1.2.0"
exit 1
fi
echo "🔨 Building $VERSION..."
# Remove old build files but keep .git
rm -rf dist/assets dist/index.html dist/*.js dist/*.css dist/vite.svg
bun run build
echo "📄 Adding README..."
if [ -f public/README.md ]; then
cp public/README.md dist/README.md
fi
echo "📦 Deploying to GitHub Pages..."
cd dist
git add .
git commit -m "Deploy $VERSION" || echo "No changes to commit"
git push
cd ..
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
echo ""
echo "Tag private repo:"
echo " git tag $VERSION && git push origin --tags"

View File

@@ -1,30 +1,43 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Shield,
QrCode, QrCode,
RefreshCw, RefreshCw,
CheckCircle2, Lock, CheckCircle2,
Lock,
AlertCircle, AlertCircle,
Unlock, Unlock,
EyeOff, EyeOff,
FileKey, FileKey,
Info, Info,
WifiOff
} from 'lucide-react'; } from 'lucide-react';
import { PgpKeyInput } from './components/PgpKeyInput'; import { PgpKeyInput } from './components/PgpKeyInput';
import { QrDisplay } from './components/QrDisplay'; import { QrDisplay } from './components/QrDisplay';
import QRScanner from './components/QRScanner'; import QRScanner from './components/QRScanner';
import { validateBip39Mnemonic } from './lib/bip39'; import { validateBip39Mnemonic } from './lib/bip39';
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp'; import { buildPlaintext, encryptToSeed, decryptFromSeed, detectEncryptionMode } from './lib/seedpgp';
import * as openpgp from 'openpgp'; import * as openpgp from 'openpgp';
import { StorageIndicator } from './components/StorageIndicator';
import { SecurityWarnings } from './components/SecurityWarnings'; import { SecurityWarnings } from './components/SecurityWarnings';
import { ClipboardTracker } from './components/ClipboardTracker';
import { ReadOnly } from './components/ReadOnly';
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto'; 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); 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() { function App() {
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup'); const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
const [mnemonic, setMnemonic] = useState(''); const [mnemonic, setMnemonic] = useState('');
@@ -45,22 +58,56 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
const [showQRScanner, setShowQRScanner] = useState(false); const [showQRScanner, setShowQRScanner] = useState(false);
const [isReadOnly, setIsReadOnly] = useState(false); const [isReadOnly, setIsReadOnly] = useState(false);
const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null); 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(() => { useEffect(() => {
// When entering read-only mode, clear sensitive data for security. refreshStorage();
if (isReadOnly) { const interval = setInterval(refreshStorage, 2000);
setMnemonic(''); return () => clearInterval(interval);
setBackupMessagePassword(''); }, []);
setRestoreMessagePassword('');
setPublicKeyInput('');
setPrivateKeyInput('');
setPrivateKeyPassphrase('');
setQrPayload('');
setRestoreInput('');
setDecryptedRestoredMnemonic(null);
setError('');
}
}, [isReadOnly]);
// Cleanup session key on component unmount // Cleanup session key on component unmount
useEffect(() => { useEffect(() => {
@@ -69,6 +116,104 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
}; };
}, []); }, []);
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) => { const copyToClipboard = async (text: string) => {
if (isReadOnly) { if (isReadOnly) {
@@ -108,10 +253,13 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase); const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase);
const result = await encryptToSeedPgp({ const result = await encryptToSeed({
plaintext, plaintext,
publicKeyArmored: publicKeyInput || undefined, publicKeyArmored: publicKeyInput || undefined,
messagePassword: backupMessagePassword || undefined, messagePassword: backupMessagePassword || undefined,
mode: encryptionMode,
kruxLabel: encryptionMode === 'krux' ? kruxLabel : undefined,
kruxIterations: encryptionMode === 'krux' ? kruxIterations : undefined,
}); });
setQrPayload(result.framed); setQrPayload(result.framed);
@@ -138,11 +286,15 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
setDecryptedRestoredMnemonic(null); setDecryptedRestoredMnemonic(null);
try { try {
const result = await decryptSeedPgp({ // Auto-detect mode if not manually set
const modeToUse = detectedMode || encryptionMode;
const result = await decryptFromSeed({
frameText: restoreInput, frameText: restoreInput,
privateKeyArmored: privateKeyInput || undefined, privateKeyArmored: privateKeyInput || undefined,
privateKeyPassphrase: privateKeyPassphrase || undefined, privateKeyPassphrase: privateKeyPassphrase || undefined,
messagePassword: restoreMessagePassword || undefined, messagePassword: restoreMessagePassword || undefined,
mode: modeToUse,
}); });
// Encrypt the restored mnemonic with the session key // Encrypt the restored mnemonic with the session key
@@ -181,80 +333,40 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
setShowQRScanner(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 ( return (
<> <div className="min-h-screen bg-slate-800 text-slate-100">
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8"> <Header
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200"> onOpenSecurityModal={() => setShowSecurityModal(true)}
localItems={localItems}
{/* Header */} sessionItems={sessionItems}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between"> onOpenStorageModal={() => setShowStorageModal(true)}
<div className="flex items-center gap-3"> events={clipboardEvents}
<div className="p-2 bg-blue-600 rounded-lg shadow-lg"> onOpenClipboardModal={() => setShowClipboardModal(true)}
<Shield size={28} /> activeTab={activeTab}
</div> setActiveTab={setActiveTab}
<div> encryptedMnemonicCache={encryptedMnemonicCache}
<h1 className="text-2xl font-bold tracking-tight"> handleLockAndClear={handleLockAndClear}
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v{__APP_VERSION__}</span> appVersion={__APP_VERSION__}
</h1> isLocked={isReadOnly}
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p> onToggleLock={handleToggleLock}
</div> />
</div> <main className="max-w-7xl mx-auto px-6 py-4">
{encryptedMnemonicCache && ( // Show only if encrypted data exists
<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>
)}
<div className="flex items-center gap-4">
{isReadOnly && (
<div className="flex items-center gap-2 text-sm text-amber-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
<WifiOff size={16} />
<span>Read-only</span>
</div>
)}
{encryptedMnemonicCache && (
<div className="flex items-center gap-2 text-sm text-green-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
<Shield size={16} />
<span>Encrypted in memory</span>
</div>
)}
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
<button
onClick={() => {
setActiveTab('backup');
setError('');
setQrPayload('');
setDecryptedRestoredMnemonic(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('');
setDecryptedRestoredMnemonic(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>
<div className="p-6 md:p-8 space-y-6"> <div className="p-6 md:p-8 space-y-6">
{/* Error Display */} {/* Error Display */}
{error && ( {error && (
@@ -269,23 +381,26 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
{/* Info Banner */} {/* Info Banner */}
{recipientFpr && activeTab === 'backup' && ( {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" /> <Info size={16} className="shrink-0 mt-0.5" />
<div> <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>
</div> </div>
)} )}
{/* Main Content Grid */} {/* 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"> <div className="md:col-span-2 space-y-6">
{activeTab === 'backup' ? ( {activeTab === 'backup' ? (
<> <>
<div className="space-y-2"> <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 <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" data-sensitive="BIP39 Mnemonic"
placeholder="Enter your 12 or 24 word seed phrase..." placeholder="Enter your 12 or 24 word seed phrase..."
value={mnemonic} value={mnemonic}
@@ -317,9 +432,12 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
</div> </div>
<div className="space-y-2"> <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 <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:..." placeholder="SEEDPGP1:0:ABCD:..."
value={restoreInput} value={restoreInput}
onChange={(e) => setRestoreInput(e.target.value)} onChange={(e) => setRestoreInput(e.target.value)}
@@ -345,7 +463,9 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
<input <input
type="password" type="password"
data-sensitive="Message Password" 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-blue-500 transition-all" 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..." placeholder="Unlock private key..."
value={privateKeyPassphrase} value={privateKeyPassphrase}
onChange={(e) => setPrivateKeyPassphrase(e.target.value)} onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
@@ -359,11 +479,73 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
</div> </div>
{/* Security Panel */} {/* 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"> <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"> {/* Removed h3 */}
<Lock size={14} /> Security Options
</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"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
@@ -371,14 +553,20 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
<Lock className="absolute left-3 top-3 text-slate-400" size={16} /> <Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input <input
type="password" 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" 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 ${
placeholder="Optional password..." isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."}
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword} value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)} onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
readOnly={isReadOnly} readOnly={isReadOnly}
/> />
</div> </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> </div>
@@ -390,7 +578,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
checked={hasBip39Passphrase} checked={hasBip39Passphrase}
onChange={(e) => setHasBip39Passphrase(e.target.checked)} onChange={(e) => setHasBip39Passphrase(e.target.checked)}
disabled={isReadOnly} disabled={isReadOnly}
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all" 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"> <span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
BIP39 25th word active BIP39 25th word active
@@ -399,12 +587,6 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
</div> </div>
)} )}
<ReadOnly
isReadOnly={isReadOnly}
onToggle={setIsReadOnly}
appVersion={__APP_VERSION__}
buildHash={__BUILD_HASH__}
/>
</div> </div>
{/* Action Button */} {/* Action Button */}
@@ -412,7 +594,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
<button <button
onClick={handleBackup} onClick={handleBackup}
disabled={!mnemonic || loading || isReadOnly} disabled={!mnemonic || loading || isReadOnly}
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-blue-600 disabled:hover:to-blue-700" className="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 ? ( {loading ? (
<RefreshCw className="animate-spin" size={20} /> <RefreshCw className="animate-spin" size={20} />
@@ -441,7 +623,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
{/* QR Output */} {/* QR Output */}
{qrPayload && activeTab === 'backup' && ( {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="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} /> <QrDisplay value={qrPayload} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -464,7 +646,7 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
readOnly readOnly
value={qrPayload} value={qrPayload}
onFocus={(e) => e.currentTarget.select()} 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"> <p className="text-[11px] text-slate-500">
Tip: click the box to select all, or use Copy. Tip: click the box to select all, or use Copy.
@@ -490,7 +672,9 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
</div> </div>
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm"> <div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm">
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words"> <p className={`font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words ${
isReadOnly ? 'blur-md select-none' : ''
}`}>
{decryptedRestoredMnemonic} {decryptedRestoredMnemonic}
</p> </p>
</div> </div>
@@ -498,14 +682,12 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
</div> </div>
)} )}
</div> </div>
</div> </main>
<Footer
{/* Footer */} appVersion={__APP_VERSION__}
<div className="mt-8 text-center text-xs text-slate-500"> buildHash={__BUILD_HASH__}
<p>SeedPGP v{__APP_VERSION__} OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p> buildTimestamp={__BUILD_TIMESTAMP__}
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p> />
</div>
</div>
{/* QR Scanner Modal */} {/* QR Scanner Modal */}
{showQRScanner && ( {showQRScanner && (
@@ -518,23 +700,105 @@ import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } fr
onClose={() => setShowQRScanner(false)} onClose={() => setShowQRScanner(false)}
/> />
)} )}
<div className="max-w-4xl mx-auto p-8">
<h1>SeedPGP v1.2.0</h1>
{/* ... rest of your app ... */}
</div>
{/* Floating Storage Monitor - bottom right */} {/* Security Modal */}
{!isReadOnly && ( {showSecurityModal && (
<> <div
<StorageIndicator /> 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 /> <SecurityWarnings />
<ClipboardTracker /> </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; 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>
);
};

View File

@@ -1,184 +0,0 @@
import { useState, useEffect } from 'react';
interface ClipboardEvent {
timestamp: Date;
field: string;
length: number; // Show length without storing actual content
}
export const ClipboardTracker = () => {
const [events, setEvents] = useState<ClipboardEvent[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
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) + '...';
}
}
}
setEvents(prev => [
{ timestamp: new Date(), field, length },
...prev.slice(0, 9) // Keep last 10 events
]);
// Auto-expand on first copy
if (events.length === 0) {
setIsExpanded(true);
}
};
document.addEventListener('copy', handleCopy as EventListener);
return () => document.removeEventListener('copy', handleCopy as EventListener);
}, [events.length]);
const clearClipboard = async () => {
try {
// Actually clear the system clipboard
await navigator.clipboard.writeText('');
// Clear history
setEvents([]);
// 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);
setEvents([]);
alert('✅ History cleared (clipboard may require manual clearing)');
}
};
return (
<div className="fixed bottom-24 right-4 z-50 max-w-sm">
<div className={`rounded-lg shadow-lg border-2 transition-all ${events.length > 0
? 'bg-orange-50 border-orange-400'
: 'bg-gray-50 border-gray-300'
}`}>
{/* Header */}
<div
className={`px-4 py-3 cursor-pointer flex items-center justify-between rounded-t-lg transition-colors ${events.length > 0 ? 'hover:bg-orange-100' : 'hover:bg-gray-100'
}`}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="text-lg">📋</span>
<span className="font-semibold text-sm text-gray-700">Clipboard Activity</span>
</div>
<div className="flex items-center gap-2">
{events.length > 0 && (
<span className="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full font-medium">
{events.length}
</span>
)}
<span className="text-gray-400 text-sm">{isExpanded ? '▼' : '▶'}</span>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 border-t border-gray-300">
{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={(e) => {
e.stopPropagation(); // Prevent collapse toggle
clearClipboard();
}}
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>
)}
</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

@@ -52,7 +52,7 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
return ( return (
<div className="space-y-2"> <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"> <span className="flex items-center gap-2">
{Icon && <Icon size={14} />} {label} {Icon && <Icon size={14} />} {label}
</span> </span>
@@ -69,7 +69,8 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
onDrop={handleDrop} onDrop={handleDrop}
> >
<textarea <textarea
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 ${isDragging && !readOnly ? '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} placeholder={placeholder}
value={value} value={value}
@@ -77,8 +78,8 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
readOnly={readOnly} readOnly={readOnly}
/> />
{isDragging && !readOnly && ( {isDragging && !readOnly && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-50/90 rounded-xl border-2 border-dashed border-blue-500 pointer-events-none z-10"> <div className="absolute inset-0 flex items-center justify-center bg-teal-50/90 rounded-xl border-2 border-dashed border-teal-500 pointer-events-none z-10">
<div className="text-blue-600 font-bold flex flex-col items-center animate-bounce"> <div className="text-teal-600 font-bold flex flex-col items-center animate-bounce">
<Upload size={24} /> <Upload size={24} />
<span className="text-sm mt-2">Drop Key File Here</span> <span className="text-sm mt-2">Drop Key File Here</span>
</div> </div>

View File

@@ -159,7 +159,7 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
<div className="space-y-3"> <div className="space-y-3">
<button <button
onClick={startCamera} 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} /> <Camera size={20} />
Use Camera Use Camera
@@ -184,7 +184,7 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
{/* Info Box */} {/* Info Box */}
<div className="pt-4 border-t border-slate-200"> <div className="pt-4 border-t border-slate-200">
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed"> <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> <div>
<p><strong>Camera:</strong> Requires HTTPS or localhost</p> <p><strong>Camera:</strong> Requires HTTPS or localhost</p>
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</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 */} {/* File Processing View */}
{scanMode === 'file' && scanning && ( {scanMode === 'file' && scanning && (
<div className="py-8 text-center"> <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> <p className="mt-3 text-sm text-slate-600">Processing image...</p>
</div> </div>
)} )}

View File

@@ -17,7 +17,7 @@ export function ReadOnly({ isReadOnly, onToggle, buildHash, appVersion }: ReadOn
type="checkbox" type="checkbox"
checked={isReadOnly} checked={isReadOnly}
onChange={(e) => onToggle(e.target.checked)} onChange={(e) => onToggle(e.target.checked)}
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all" 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"> <span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
Read-only Mode Read-only Mode

View File

@@ -1,28 +1,8 @@
import { useState } from 'react'; import React from 'react';
export const SecurityWarnings = () => {
const [isExpanded, setIsExpanded] = useState(false);
export const SecurityWarnings: React.FC = () => {
return ( return (
<div className="fixed bottom-4 left-4 z-50 max-w-sm"> <div className="space-y-3">
<div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-lg">
{/* Header */}
<div
className="px-4 py-3 cursor-pointer flex items-center justify-between hover:bg-yellow-100 transition-colors rounded-t-lg"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="text-lg"></span>
<span className="font-semibold text-sm text-yellow-900">Security Limitations</span>
</div>
<span className="text-yellow-600 text-sm">{isExpanded ? '▼' : '▶'}</span>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="px-4 py-3 border-t border-yellow-300 space-y-3 max-h-96 overflow-y-auto">
<Warning <Warning
icon="🧵" icon="🧵"
title="JavaScript Strings are Immutable" title="JavaScript Strings are Immutable"
@@ -59,23 +39,28 @@ export const SecurityWarnings = () => {
description="If hosted online: DNS, HTTPS, CDN, and browser can see usage patterns. Use offline/local for maximum security." description="If hosted online: DNS, HTTPS, CDN, and browser can see usage patterns. Use offline/local for maximum security."
/> />
<div className="pt-2 border-t border-yellow-300 text-xs text-yellow-800"> <div className="pt-3 border-t border-slate-600 text-xs text-slate-400">
<strong>Recommendation:</strong> Use this tool on a dedicated offline device. <strong className="text-slate-300">Recommendation:</strong>{' '}
Clear browser data after each use. Never use on shared/public computers. Use this tool on a dedicated offline device. Clear browser data after each use. Never use on shared/public computers.
</div>
</div>
)}
</div> </div>
</div> </div>
); );
}; };
const Warning = ({ icon, title, description }: { icon: string; title: string; description: string }) => ( const Warning = ({
<div className="flex gap-2 text-xs"> icon,
<span className="text-base flex-shrink-0">{icon}</span> 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>
<div className="font-semibold text-yellow-900 mb-0.5">{title}</div> <div className="font-semibold text-slate-200 mb-1">{title}</div>
<div className="text-yellow-800 leading-relaxed">{description}</div> <div className="text-slate-400 leading-relaxed">{description}</div>
</div> </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

@@ -1,150 +0,0 @@
import { useState, useEffect } from 'react';
interface StorageItem {
key: string;
value: string;
size: number;
isSensitive: boolean;
}
export const StorageIndicator = () => {
const [localItems, setLocalItems] = useState<StorageItem[]>([]);
const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
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);
}, []);
const totalItems = localItems.length + sessionItems.length;
const sensitiveCount = [...localItems, ...sessionItems].filter(i => i.isSensitive).length;
return (
<div className="fixed bottom-4 right-4 z-50 max-w-md">
<div className={`bg-white rounded-lg shadow-lg border-2 ${sensitiveCount > 0 ? 'border-red-400' : 'border-gray-300'
} transition-all duration-200`}>
{/* Header Bar */}
<div
className={`px-4 py-3 rounded-t-lg cursor-pointer flex items-center justify-between ${sensitiveCount > 0 ? 'bg-red-50' : 'bg-gray-50'
} hover:opacity-90 transition-opacity`}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<span className="text-lg">🗄</span>
<span className="font-semibold text-sm text-gray-700">Storage Monitor</span>
</div>
<div className="flex items-center gap-2">
{sensitiveCount > 0 && (
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full font-medium">
{sensitiveCount}
</span>
)}
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${totalItems > 0 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
}`}>
{totalItems === 0 ? '✓ Empty' : `${totalItems} item${totalItems !== 1 ? 's' : ''}`}
</span>
<span className="text-gray-400 text-sm">{isExpanded ? '▼' : '▶'}</span>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 max-h-96 overflow-y-auto">
{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>
)}
</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,15 @@
import * as openpgp from "openpgp"; import * as openpgp from "openpgp";
import { base45Encode, base45Decode } from "./base45"; import { base45Encode, base45Decode } from "./base45";
import { crc16CcittFalse } from "./crc16"; 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) // Configure OpenPGP.js (disable warnings)
openpgp.config.showComment = false; openpgp.config.showComment = false;
@@ -194,3 +202,135 @@ export async function decryptSeedPgp(params: {
return obj; 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';
}

View File

@@ -12,3 +12,39 @@ export type ParsedSeedPgpFrame = {
crc16: string; crc16: string;
b45: 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;
};

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

@@ -8,3 +8,4 @@ declare module '*.css' {
declare const __APP_VERSION__: string; declare const __APP_VERSION__: string;
declare const __BUILD_HASH__: string; declare const __BUILD_HASH__: string;
declare const __BUILD_TIMESTAMP__: string;

BIN
test.pgp

Binary file not shown.

View File

@@ -11,8 +11,16 @@ const appVersion = packageJson.version
const gitHash = execSync('git rev-parse --short HEAD').toString().trim() const gitHash = execSync('git rev-parse --short HEAD').toString().trim()
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
base: process.env.CF_PAGES ? '/' : '/seedpgp-web-app/', 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) publicDir: 'public', // ← Explicitly set (should be default)
build: { build: {
outDir: 'dist', outDir: 'dist',
@@ -21,5 +29,6 @@ export default defineConfig({
define: { define: {
'__APP_VERSION__': JSON.stringify(appVersion), '__APP_VERSION__': JSON.stringify(appVersion),
'__BUILD_HASH__': JSON.stringify(gitHash), '__BUILD_HASH__': JSON.stringify(gitHash),
'__BUILD_TIMESTAMP__': JSON.stringify(new Date().toISOString()),
} }
}) })