diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ccc397f..05655b7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,7 +1,7 @@ Here's your `DEVELOPMENT.md`: ```markdown -# Development Guide - SeedPGP v1.1.0 +# Development Guide - SeedPGP v1.4.4 ## Architecture Quick Reference diff --git a/GEMINI.md b/GEMINI.md index e900f30..84528a9 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -2,10 +2,10 @@ ## Project Overview -**SeedPGP v1.4.3**: 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 -**Deploy**: GitHub Pages (public repo: `seedpgp-web-app`, private source: `seedpgp-web`) -**Live URL**: +**Deploy**: Cloudflare Pages (private repo: `seedpgp-web`) +**Live URL**: ## Core Constraints @@ -314,9 +314,26 @@ await window.runSessionCryptoTest() --- -## Current Version: v1.4.3 +## Current Version: v1.4.4 -*Please update the "Recent Changes", "Known Limitations", and "Next Priorities" sections to reflect the current state of the project.* +**Recent Changes (v1.4.4):** +- Enhanced security documentation with explicit threat model +- Improved README with simple examples and best practices +- Better air-gapped usage guidance for maximum security +- Version bump with security audit improvements + +**Known Limitations (Critical):** +1. **Browser extensions** can read DOM, memory, keystrokes - use dedicated browser +2. **Memory persistence** - JavaScript cannot force immediate memory wiping +3. **XSS attacks** if hosting server is compromised - host locally +4. **Hardware keyloggers** - physical device compromise not protected against +5. **Supply chain attacks** - compromised dependencies possible +6. **Quantum computers** - future threat to current cryptography + +**Next Priorities:** +1. Enhanced BIP39 validation (full wordlist + checksum) +2. Multi-frame support for larger payloads +3. Hardware wallet integration (Trezor/Keystone) --- diff --git a/README.md b/README.md index 580a0a5..79da551 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SeedPGP v1.4.3 +# SeedPGP v1.4.4 **Secure BIP39 mnemonic backup using PGP encryption and QR codes** @@ -6,27 +6,199 @@ A client-side web app for encrypting cryptocurrency seed phrases with OpenPGP an **Live App:** -## Features +--- -- πŸ” **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 and Vite for optimal performance -- πŸ”’ **Session-Key Encryption**: Ephemeral AES-GCM-256 encryption for in-memory protection -- πŸ›‘οΈ **CSP Enforcement**: Real Content Security Policy headers block all network requests -- πŸ“Έ **QR Scanner**: Camera and file upload support for scanning encrypted QR codes -- πŸ‘οΈ **Security Monitoring**: Real-time storage monitoring and clipboard tracking +## ✨ Quick Start -## Installation +### πŸ”’ Backup Your Seed (in 30 seconds) +1. **Run locally** (recommended for maximum security): + ```bash + git clone https://github.com/kccleoc/seedpgp-web.git + cd seedpgp-web + bun install + bun run dev + # Open http://localhost:5173 + ``` + +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 repository +# Clone and install git clone https://github.com/kccleoc/seedpgp-web.git cd seedpgp-web - -# Install dependencies bun install # Run tests @@ -34,354 +206,253 @@ bun test # Start development server bun run dev -``` - -## Usage - -### Web Interface - -Visit or run locally: - -```bash -bun run dev # Open http://localhost:5173 ``` -**Backup Flow:** - -1. Enter your BIP39 mnemonic (12/18/24 words) -2. Import PGP public key or set encryption password -3. Click "Backup" to encrypt and generate QR code -4. Save/print QR code for offline storage - -**Restore Flow (Web Interface):** - -1. Scan QR code or paste encrypted text -2. Import PGP private key or enter password -3. Click "Restore" to decrypt mnemonic -4. Mnemonic auto-clears after 10 seconds - -**Offline/Manual Restore:** - -For airgapped recovery without the web interface, use the command-line method documented in [RECOVERY_PLAYBOOK.md](RECOVERY_PLAYBOOK.md): - -1. Extract Base45 payload from SEEDPGP1 frame -2. Decode Base45 to PGP binary -3. Decrypt with GPG using private key or password -4. Parse JSON output to recover mnemonic - -See [RECOVERY_PLAYBOOK.md](RECOVERY_PLAYBOOK.md) for complete step-by-step instructions. - -### API Usage - -```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 - -**Production:** Cloudflare Pages (auto-deploys from `main` branch) -**Live URL:** - -### Cloudflare Pages Setup - -This project is deployed on Cloudflare Pages for enhanced security features: - -1. **Repository:** `seedpgp-web` (private repo) -2. **Build command:** `bun run build` -3. **Output directory:** `dist/` -4. **Security headers:** Automatically enforced via `public/_headers` - -### Benefits Over GitHub Pages - -- βœ… Real CSP header enforcement (blocks network requests at browser level) -- βœ… Custom security headers (X-Frame-Options, X-Content-Type-Options) -- βœ… Auto-deploy on push to main -- βœ… Build preview for PRs -- βœ… Better performance (global CDN) -- βœ… Cost: $0/month - -### Deployment Workflow - +### Production Build ```bash -# Commit feature -git add src/ -git commit -m "feat(v1.x): description" - -# Tag version (triggers auto-deploy to Cloudflare) -git tag v1.x.x -git push origin main --tags +bun run build # Build to dist/ +bun run preview # Preview production build ``` -**No manual deployment needed!** Cloudflare Pages auto-deploys when you push to `main`. +--- -## Frame Format +## πŸ” Advanced Security Features -``` -SEEDPGP1:FRAME:CRC16:BASE45DATA +### Session-Key Encryption +- **AES-GCM-256** ephemeral keys for in-memory protection +- Auto-destroys on tab close/navigation +- Manual lock/clear button for immediate wiping -SEEDPGP1 - Protocol identifier and version -0 - Frame number (0 = single frame) -ABCD - 4-digit hex CRC16-CCITT-FALSE checksum -BASE45 - Base45-encoded PGP message -``` +### Storage Monitoring +- Real-time tracking of localStorage/sessionStorage +- Alerts for sensitive data detection +- Visual indicators of storage usage -## API Reference +### Clipboard Protection +- Tracks all copy operations +- Shows what was copied and when +- One-click history clearing -### `buildPlaintext(mnemonic, bip39PassphraseUsed, recipientFingerprints?)` +### Read-Only Mode +- Blurs all sensitive data +- Disables all inputs +- Prevents clipboard operations +- Perfect for demonstrations or shared screens -Creates a SeedPGP plaintext object. +--- -**Parameters:** +## πŸ“– API Reference -- `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 +### Core Functions -**Returns:** `SeedPgpPlaintext` object - -### `encryptToSeedPgp(params)` - -Encrypts a plaintext object to SeedPGP format. - -**Parameters:** +#### `encryptToSeed(params)` +Encrypts a mnemonic to SeedPGP format. ```typescript -{ - plaintext: SeedPgpPlaintext; - publicKeyArmored?: string; // PGP public key (PKESK) - messagePassword?: string; // Symmetric password (SKESK) +interface EncryptionParams { + plaintext: string | SeedPgpPlaintext; // Mnemonic or plaintext object + publicKeyArmored?: string; // PGP public key (optional) + messagePassword?: string; // Password (optional) + mode?: 'pgp' | 'krux'; // Encryption mode + kruxLabel?: string; // Label for Krux mode + kruxIterations?: number; // PBKDF2 iterations for Krux } + +const result = await encryptToSeed({ + plaintext: "your mnemonic here", + messagePassword: "optional-password", +}); +// Returns: { framed: string, pgpBytes?: Uint8Array, recipientFingerprint?: string } ``` -**Returns:** - -```typescript -{ - framed: string; // SEEDPGP1 frame - pgpBytes: Uint8Array; // Raw PGP message - recipientFingerprint?: string; // Key fingerprint -} -``` - -### `decryptSeedPgp(params)` - +#### `decryptFromSeed(params)` Decrypts a SeedPGP frame. -**Parameters:** - ```typescript -{ - frameText: string; // SEEDPGP1 frame - privateKeyArmored?: string; // PGP private key - privateKeyPassphrase?: string; // Key unlock password - messagePassword?: string; // SKESK password +interface DecryptionParams { + frameText: string; // SEEDPGP1 frame or KEF hex + privateKeyArmored?: string; // PGP private key (optional) + privateKeyPassphrase?: string; // Key password (optional) + messagePassword?: string; // Message password (optional) + mode?: 'pgp' | 'krux'; // Encryption mode } + +const plaintext = await decryptFromSeed({ + frameText: "SEEDPGP1:0:ABCD:...", + messagePassword: "your-password", +}); +// Returns: SeedPgpPlaintext { v: 1, t: "bip39", w: string, l: "en", pp: number } ``` -**Returns:** `SeedPgpPlaintext` object +### Frame Format +``` +SEEDPGP1:FRAME:CRC16:BASE45DATA +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”¬β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ + Protocol & Frame CRC16 Base45-encoded + Version Number Check PGP Message -## Testing +Examples: +β€’ SEEDPGP1:0:ABCD:J9ESODB... # Single frame +β€’ KEF:0123456789ABCDEF... # Krux Encryption Format (hex) +``` +--- + +## πŸš€ Deployment Options + +### Option 1: Localhost (Most Secure) +```bash +# Run on airgapped machine +bun run dev -- --host 127.0.0.1 +# Browser only connects to localhost, no external traffic +``` + +### Option 2: Self-Hosted (Balanced) +- Build: `bun run build` +- Serve `dist/` via NGINX/Apache with HTTPS +- Set CSP headers (see `public/_headers`) + +### Option 3: Cloudflare Pages (Convenient) +- Auto-deploys from GitHub +- Built-in CDN and security headers +- [seedpgp-web.pages.dev](https://seedpgp-web.pages.dev) + +--- + +## πŸ§ͺ Testing & Verification + +### Test Suite ```bash # Run all tests bun test -# Run with verbose output -bun test --verbose +# Run specific test categories +bun test --test-name-pattern="Trezor" # BIP39 test vectors +bun test --test-name-pattern="CRC" # Integrity checks +bun test --test-name-pattern="Krux" # Krux compatibility -# Watch mode (auto-rerun on changes) +# Watch mode (development) bun test --watch ``` ### Test Coverage +- βœ… **15 comprehensive tests** including edge cases +- βœ… **8 official Trezor BIP39 test vectors** +- βœ… **CRC16 integrity validation** (corruption detection) +- βœ… **Wrong key/password** rejection testing +- βœ… **Frame format parsing** (malformed input handling) -- βœ… 15 comprehensive tests -- βœ… 8 official Trezor BIP39 test vectors -- βœ… Edge cases (wrong key, wrong passphrase) -- βœ… Frame format validation -- βœ… CRC16 integrity checking +--- -## Security Considerations - -### βœ… Best Practices - -- Uses **AES-256** for symmetric encryption -- **cv25519** provides ~128-bit security level -- **CRC16** detects QR scan errors (not cryptographic) -- Key fingerprint validation prevents wrong-key usage -- **Session-key encryption**: Ephemeral AES-GCM-256 for in-memory protection -- **CSP headers**: Browser-enforced network blocking via Cloudflare Pages - -### ⚠️ 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 Cloudflare Pages deployment at **** is for: - -- βœ… Personal use with enhanced security -- βœ… CSP enforcement blocks all network requests -- βœ… Convenient access from any device -- ⚠️ 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 -- Use an airgapped device for critical operations - -### Threat Model (Honest) - -**What we protect against:** - -- Accidental persistence to localStorage/sessionStorage -- Plaintext secrets lingering in React state after use -- Clipboard history exposure (with warnings) - -**What we DON'T protect against:** - -- Active XSS or malicious browser extensions -- Memory dumps or browser crash reports -- JavaScript garbage collection timing (non-deterministic) - -## Project Structure +## πŸ“ Project Structure ``` seedpgp-web/ β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ components/ -β”‚ β”‚ β”œβ”€β”€ PgpKeyInput.tsx # PGP key import UI -β”‚ β”‚ β”œβ”€β”€ QrDisplay.tsx # QR code generation -β”‚ β”‚ β”œβ”€β”€ QrScanner.tsx # Camera + file scanner -β”‚ β”‚ β”œβ”€β”€ ReadOnly.tsx # Read-only mode toggle -β”‚ β”‚ β”œβ”€β”€ StorageIndicator.tsx # Storage monitoring -β”‚ β”‚ β”œβ”€β”€ SecurityWarnings.tsx # Context alerts -β”‚ β”‚ └── ClipboardTracker.tsx # Clipboard monitoring +β”‚ β”œβ”€β”€ components/ # React UI components +β”‚ β”‚ β”œβ”€β”€ PgpKeyInput.tsx # PGP key import (drag & drop) +β”‚ β”‚ β”œβ”€β”€ QrDisplay.tsx # QR code generation +β”‚ β”‚ β”œβ”€β”€ QRScanner.tsx # Camera + file scanning +β”‚ β”‚ β”œβ”€β”€ SecurityWarnings.tsx # Threat model display +β”‚ β”‚ β”œβ”€β”€ StorageDetails.tsx # Storage monitoring +β”‚ β”‚ └── ClipboardDetails.tsx # Clipboard tracking β”‚ β”œβ”€β”€ lib/ -β”‚ β”‚ β”œβ”€β”€ seedpgp.ts # Core encryption/decryption -β”‚ β”‚ β”œβ”€β”€ seedpgp.test.ts # Test vectors -β”‚ β”‚ β”œβ”€β”€ sessionCrypto.ts # Ephemeral session keys -β”‚ β”‚ β”œβ”€β”€ base45.ts # Base45 codec -β”‚ β”‚ β”œβ”€β”€ crc16.ts # CRC16-CCITT-FALSE -β”‚ β”‚ β”œβ”€β”€ qr.ts # QR utilities -β”‚ β”‚ └── types.ts # TypeScript definitions -β”‚ β”œβ”€β”€ App.tsx # Main application -β”‚ └── main.tsx # React entry point +β”‚ β”‚ β”œβ”€β”€ seedpgp.ts # Core encryption/decryption +β”‚ β”‚ β”œβ”€β”€ sessionCrypto.ts # AES-GCM session key management +β”‚ β”‚ β”œβ”€β”€ krux.ts # Krux KEF compatibility +β”‚ β”‚ β”œβ”€β”€ bip39.ts # BIP39 validation +β”‚ β”‚ β”œβ”€β”€ base45.ts # Base45 encoding/decoding +β”‚ β”‚ └── crc16.ts # CRC16-CCITT-FALSE checksums +β”‚ β”œβ”€β”€ App.tsx # Main application +β”‚ └── main.tsx # React entry point β”œβ”€β”€ public/ -β”‚ └── _headers # Cloudflare CSP headers +β”‚ └── _headers # Cloudflare security headers β”œβ”€β”€ package.json -β”œβ”€β”€ vite.config.ts # Vite configuration -β”œβ”€β”€ GEMINI.md # AI agent project brief -β”œβ”€β”€ RECOVERY_PLAYBOOK.md # Offline recovery guide -└── README.md # This file +β”œβ”€β”€ vite.config.ts +β”œβ”€β”€ RECOVERY_PLAYBOOK.md # Offline recovery guide +└── README.md # This file ``` -## Tech Stack - -- **Runtime**: [Bun](https://bun.sh) v1.3.6+ -- **Language**: TypeScript (strict mode) -- **Crypto**: [OpenPGP.js](https://openpgpjs.org) v6.3.0 -- **Framework**: React + Vite -- **UI**: Tailwind CSS -- **Icons**: lucide-react -- **QR**: html5-qrcode, qrcode -- **Testing**: Bun test runner -- **Deployment**: Cloudflare Pages - -## Version History - -### v1.4.3 (2026-01-30) - -- βœ… Fixed textarea contrast for readability -- βœ… Fixed overlapping floating boxes -- βœ… Polished UI with modern crypto wallet design -- βœ… Updated background color to be lighter - -### v1.4.2 (2026-01-30) - -- βœ… Migrated to Cloudflare Pages for real CSP enforcement -- βœ… Added "Encrypted in memory" badge when mnemonic locked -- βœ… Improved security header configuration -- βœ… Updated deployment documentation - -### 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 -- βœ… Removed debug console logs from production - -### v1.3.0 (2026-01-28) - -- βœ… Implemented ephemeral session-key encryption (AES-GCM-256) -- βœ… Auto-clear mnemonic after QR generation (Backup flow) -- βœ… Encrypted cache for sensitive state -- βœ… Manual Lock/Clear functionality - -### v1.2.0 (2026-01-27) - -- βœ… Added storage monitoring (StorageIndicator) -- βœ… Added security warnings (context-aware) -- βœ… Added clipboard tracking -- βœ… Implemented read-only mode - -### v1.1.0 (2026-01-26) - -- βœ… Initial public release -- βœ… QR code generation and scanning -- βœ… Full BIP39 mnemonic support -- βœ… Trezor test vector validation -- βœ… Production-ready implementation - -## Roadmap - -- [ ] UI polish (modern crypto wallet design) -- [ ] Multi-frame support for larger payloads -- [ ] Hardware wallet integration -- [ ] Mobile scanning app -- [ ] Shamir Secret Sharing support -- [ ] Reproducible builds with git hash verification - -## License - -MIT License - see LICENSE file for details - -## Author - -**kccleoc** - [GitHub](https://github.com/kccleoc) - --- -⚠️ **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. +## πŸ”„ Version History + +### 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 + +### v1.4.3 (2026-01-30) +- βœ… Fixed textarea contrast for readability +- βœ… Fixed overlapping floating boxes +- βœ… Polished UI with modern crypto wallet design + +### v1.4.2 (2026-01-30) +- βœ… Migrated to Cloudflare Pages for real CSP enforcement +- βœ… Added "Encrypted in memory" badge +- βœ… Improved security header configuration + +### 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 + +[View full version history...](https://github.com/kccleoc/seedpgp-web/releases) + +--- + +## πŸ—ΊοΈ Roadmap + +### Short-term (v1.5.x) +- [ ] Enhanced BIP39 validation (full wordlist + checksum) +- [ ] Multi-frame support for larger payloads +- [ ] Hardware wallet integration (Trezor/Keystone) + +### Medium-term +- [ ] Shamir Secret Sharing support +- [ ] Mobile companion app (React Native) +- [ ] Printable paper backup templates +- [ ] Encrypted cloud backup with PBKDF2 + +### Long-term +- [ ] BIP85 child mnemonic derivation +- [ ] Quantum-resistant algorithm options +- [ ] Cross-platform desktop app (Tauri) + +--- + +## βš–οΈ License + +MIT License - see [LICENSE](LICENSE) file for details. + +## πŸ‘€ Author + +**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. \ No newline at end of file diff --git a/RECOVERY_PLAYBOOK.md b/RECOVERY_PLAYBOOK.md index 9399890..beac245 100644 --- a/RECOVERY_PLAYBOOK.md +++ b/RECOVERY_PLAYBOOK.md @@ -1,6 +1,6 @@ ## SeedPGP Recovery Playbook - Offline Recovery Guide -**Generated:** Feb 1, 2026 | **SeedPGP v1.4.3** | **Frame Format:** `SEEDPGP1:0:CRC16:BASE45_PAYLOAD` +**Generated:** Feb 3, 2026 | **SeedPGP v1.4.4** | **Frame Format:** `SEEDPGP1:0:CRC16:BASE45_PAYLOAD` *** @@ -414,8 +414,8 @@ print(f"BIP39 Passphrase used: {'YES' if data['pp'] == 1 else 'NO'}") **Print this playbook on archival paper or metal. Store separately from encrypted backups and private keys.** πŸ”’ -**Last Updated:** February 1, 2026 -**SeedPGP Version:** 1.4.3 +**Last Updated:** February 3, 2026 +**SeedPGP Version:** 1.4.4 **Frame Example CRC:** 58B5 βœ“ **Test Recovery:** [ ] Completed [ ] Not Tested diff --git a/package.json b/package.json index 14bcf45..57a7b54 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "seedpgp-web", "private": true, - "version": "1.4.3", + "version": "1.4.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index d319b21..42f175d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ import { PgpKeyInput } from './components/PgpKeyInput'; import { QrDisplay } from './components/QrDisplay'; import QRScanner from './components/QRScanner'; import { validateBip39Mnemonic } from './lib/bip39'; -import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp'; +import { buildPlaintext, encryptToSeed, decryptFromSeed, detectEncryptionMode } from './lib/seedpgp'; import * as openpgp from 'openpgp'; import { SecurityWarnings } from './components/SecurityWarnings'; import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto'; @@ -66,6 +66,12 @@ function App() { const [clipboardEvents, setClipboardEvents] = useState([]); 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 => { @@ -170,6 +176,20 @@ function App() { 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 @@ -233,10 +253,13 @@ function App() { const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase); - const result = await encryptToSeedPgp({ + const result = await encryptToSeed({ plaintext, publicKeyArmored: publicKeyInput || undefined, messagePassword: backupMessagePassword || undefined, + mode: encryptionMode, + kruxLabel: encryptionMode === 'krux' ? kruxLabel : undefined, + kruxIterations: encryptionMode === 'krux' ? kruxIterations : undefined, }); setQrPayload(result.framed); @@ -263,11 +286,15 @@ function App() { setDecryptedRestoredMnemonic(null); try { - const result = await decryptSeedPgp({ + // Auto-detect mode if not manually set + const modeToUse = detectedMode || encryptionMode; + + const result = await decryptFromSeed({ frameText: restoreInput, privateKeyArmored: privateKeyInput || undefined, privateKeyPassphrase: privateKeyPassphrase || undefined, messagePassword: restoreMessagePassword || undefined, + mode: modeToUse, }); // Encrypt the restored mnemonic with the session key @@ -459,6 +486,66 @@ function App() {
{/* Removed h3 */} + + {/* Encryption Mode Toggle */} +
+ + +

+ {encryptionMode === 'pgp' + ? 'Uses PGP keys or password' + : 'Uses passphrase only (Krux compatible)'} +

+
+ + {/* Krux-specific fields */} + {encryptionMode === 'krux' && activeTab === 'backup' && ( + <> +
+ +
+ setKruxLabel(e.target.value)} + readOnly={isReadOnly} + /> +
+

Label for identification (max 252 bytes)

+
+ +
+ +
+ setKruxIterations(Number(e.target.value))} + min={10000} + step={10000} + readOnly={isReadOnly} + /> +
+

Higher = more secure but slower (default: 200,000)

+
+ + )}
@@ -469,13 +556,17 @@ function App() { 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="Optional password..." + placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."} value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword} onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)} readOnly={isReadOnly} />
-

Symmetric encryption password (SKESK)

+

+ {encryptionMode === 'krux' + ? 'Required passphrase for Krux encryption' + : 'Symmetric encryption password (SKESK)'} +

diff --git a/src/lib/krux.test.ts b/src/lib/krux.test.ts new file mode 100644 index 0000000..f4c0a2c --- /dev/null +++ b/src/lib/krux.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/lib/krux.ts b/src/lib/krux.ts new file mode 100644 index 0000000..6f834e8 --- /dev/null +++ b/src/lib/krux.ts @@ -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 = { + 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; + + 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 { + 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 { + 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 }; +} \ No newline at end of file diff --git a/src/lib/seedpgp.ts b/src/lib/seedpgp.ts index 4318e34..ef65784 100644 --- a/src/lib/seedpgp.ts +++ b/src/lib/seedpgp.ts @@ -1,7 +1,15 @@ import * as openpgp from "openpgp"; import { base45Encode, base45Decode } from "./base45"; import { crc16CcittFalse } from "./crc16"; -import type { SeedPgpPlaintext, ParsedSeedPgpFrame } from "./types"; +import { encryptToKrux, decryptFromKrux, hexToBytes } from "./krux"; +import type { + SeedPgpPlaintext, + ParsedSeedPgpFrame, + EncryptionMode, + EncryptionParams, + DecryptionParams, + EncryptionResult +} from "./types"; // Configure OpenPGP.js (disable warnings) openpgp.config.showComment = false; @@ -194,3 +202,135 @@ export async function decryptSeedPgp(params: { return obj; } + +/** + * Unified encryption function supporting both PGP and Krux modes + */ +export async function encryptToSeed(params: EncryptionParams): Promise { + 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 { + 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'; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 532f82d..d725c90 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,3 +12,39 @@ export type ParsedSeedPgpFrame = { crc16: string; b45: string; }; + +// Krux KEF types +export type KruxEncryptionParams = { + label?: string; + iterations?: number; + version?: number; +}; + +export type EncryptionMode = 'pgp' | 'krux'; + +export type EncryptionParams = { + plaintext: SeedPgpPlaintext | string; + publicKeyArmored?: string; + messagePassword?: string; + mode?: EncryptionMode; + kruxLabel?: string; + kruxIterations?: number; + kruxVersion?: number; +}; + +export type DecryptionParams = { + frameText: string; + privateKeyArmored?: string; + privateKeyPassphrase?: string; + messagePassword?: string; + mode?: EncryptionMode; +}; + +export type EncryptionResult = { + framed: string; + pgpBytes?: Uint8Array; + recipientFingerprint?: string; + label?: string; + version?: number; + iterations?: number; +};