mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185efe454f | ||
|
|
75da988968 | ||
|
|
a0133369b6 | ||
|
|
7c4fc1460c | ||
|
|
0ab99ce493 | ||
|
|
f5d50d9326 | ||
|
|
d4d5807342 | ||
|
|
489d3fea3b | ||
|
|
54195ead8d | ||
|
|
008406ef59 | ||
|
|
cf3412b235 | ||
|
|
a021044a19 | ||
|
|
f4538b9b6c | ||
|
|
aa06c9ae27 | ||
|
|
49d73a7ae4 | ||
|
|
7d48d2ade2 | ||
|
|
857f075e26 | ||
|
|
9096a1485c | ||
|
|
9c84f13f2a | ||
|
|
e25cd9ebf9 | ||
|
|
e8b0085689 | ||
|
|
26fb4ca92e | ||
|
|
48e8acbe32 | ||
|
|
c2aeb4ce83 | ||
|
|
b918d88a47 | ||
|
|
3f37596b3b | ||
|
|
ec722befef | ||
|
|
e3ade8eab1 | ||
|
|
9ba7645663 | ||
|
|
4353ec0cc2 | ||
|
|
a7ab757669 | ||
|
|
16ca734271 | ||
|
|
a607cd74cf | ||
|
|
2a7ac1cce0 | ||
|
|
7564ddc7c9 | ||
|
|
32dff01132 | ||
|
|
81fbd210ca | ||
|
|
5ea3b92ab1 | ||
|
|
eec194fbba | ||
|
|
24c714fb2f | ||
|
|
eeb5184b8a | ||
|
|
422fe04a12 | ||
|
|
ebeea79a33 | ||
|
|
faf58dc49d | ||
|
|
46982794cc | ||
|
|
9ffdbbd50f | ||
|
|
b024856c08 | ||
|
|
a919e8bf09 | ||
|
|
e4516f3d19 | ||
|
|
4b5bd80be6 | ||
|
|
8124375537 | ||
|
|
2107dab501 | ||
|
|
0f397859e6 | ||
|
|
d4919f3d93 | ||
|
|
c1b1f566df | ||
|
|
6bbfe665cd | ||
|
|
8e656749fe | ||
|
|
5a4018dcfe | ||
|
|
94764248d0 | ||
|
|
a4a737540a | ||
|
|
b7da81c5d4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
REFERENCE
|
||||||
|
.Ref
|
||||||
291
DEVELOPMENT.md
291
DEVELOPMENT.md
@@ -1,291 +0,0 @@
|
|||||||
Here's your `DEVELOPMENT.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Development Guide - SeedPGP v1.1.0
|
|
||||||
|
|
||||||
## Architecture Quick Reference
|
|
||||||
|
|
||||||
### Core Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/types.ts
|
|
||||||
interface SeedPgpPlaintext {
|
|
||||||
v: number; // Version (always 1)
|
|
||||||
t: string; // Type ("bip39")
|
|
||||||
w: string; // Mnemonic words (normalized)
|
|
||||||
l: string; // Language ("en")
|
|
||||||
pp: number; // BIP39 passphrase used? (0 or 1)
|
|
||||||
fpr?: string[]; // Optional recipient fingerprints
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedSeedPgpFrame {
|
|
||||||
kind: "single"; // Frame type
|
|
||||||
crc16: string; // 4-digit hex checksum
|
|
||||||
b45: string; // Base45 payload
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frame Format
|
|
||||||
|
|
||||||
```
|
|
||||||
SEEDPGP1:0:ABCD:BASE45DATA
|
|
||||||
|
|
||||||
SEEDPGP1 - Protocol identifier + version
|
|
||||||
0 - Frame number (single frame)
|
|
||||||
ABCD - CRC16-CCITT-FALSE checksum (4 hex digits)
|
|
||||||
BASE45 - Base45-encoded PGP binary message
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Functions
|
|
||||||
|
|
||||||
#### Encryption Flow
|
|
||||||
```typescript
|
|
||||||
buildPlaintext(mnemonic, bip39PassphraseUsed, recipientFingerprints?)
|
|
||||||
→ SeedPgpPlaintext
|
|
||||||
|
|
||||||
encryptToSeedPgp({ plaintext, publicKeyArmored?, messagePassword? })
|
|
||||||
→ { framed: string, pgpBytes: Uint8Array, recipientFingerprint?: string }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Decryption Flow
|
|
||||||
```typescript
|
|
||||||
decryptSeedPgp({ frameText, privateKeyArmored?, privateKeyPassphrase?, messagePassword? })
|
|
||||||
→ SeedPgpPlaintext
|
|
||||||
|
|
||||||
frameDecodeToPgpBytes(frameText)
|
|
||||||
→ Uint8Array (with CRC16 validation)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Encoding/Decoding
|
|
||||||
```typescript
|
|
||||||
frameEncode(pgpBinary: Uint8Array) → "SEEDPGP1:0:CRC16:BASE45"
|
|
||||||
frameParse(text: string) → ParsedSeedPgpFrame
|
|
||||||
frameDecodeToPgpBytes(frameText: string) → Uint8Array
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"openpgp": "^6.3.0", // PGP encryption (curve25519Legacy)
|
|
||||||
"bun-types": "latest", // Bun runtime types
|
|
||||||
"react": "^18.x", // UI framework
|
|
||||||
"vite": "^5.x" // Build tool
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenPGP.js v6 Quirks
|
|
||||||
|
|
||||||
⚠️ **Important compatibility notes:**
|
|
||||||
|
|
||||||
1. **Empty password array bug**: Never pass `passwords: []` to `decrypt()`. Only include if non-empty:
|
|
||||||
```typescript
|
|
||||||
if (msgPw) {
|
|
||||||
decryptOptions.passwords = [msgPw];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Curve naming**: Use `curve25519Legacy` (not `curve25519`) in `generateKey()`
|
|
||||||
|
|
||||||
3. **Key validation**: Always call `getEncryptionKey()` to verify public key has usable subkeys
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
seedpgp-web/
|
|
||||||
├── src/
|
|
||||||
│ ├── lib/
|
|
||||||
│ │ ├── seedpgp.ts # Core encrypt/decrypt logic
|
|
||||||
│ │ ├── seedpgp.test.ts # Test vectors (15 tests)
|
|
||||||
│ │ ├── base45.ts # Base45 encoder/decoder
|
|
||||||
│ │ ├── crc16.ts # CRC16-CCITT-FALSE
|
|
||||||
│ │ └── types.ts # TypeScript interfaces
|
|
||||||
│ ├── App.tsx # React UI entry
|
|
||||||
│ └── main.tsx # Vite bootstrap
|
|
||||||
├── package.json
|
|
||||||
├── tsconfig.json
|
|
||||||
├── vite.config.ts
|
|
||||||
├── README.md
|
|
||||||
└── DEVELOPMENT.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All tests
|
|
||||||
bun test
|
|
||||||
|
|
||||||
# Watch mode
|
|
||||||
bun test --watch
|
|
||||||
|
|
||||||
# Verbose output
|
|
||||||
bun test --verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev # Start Vite dev server
|
|
||||||
bun run build # Production build
|
|
||||||
bun run preview # Preview production build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Features
|
|
||||||
|
|
||||||
1. **Write tests first** in `seedpgp.test.ts`
|
|
||||||
2. **Implement in** `src/lib/seedpgp.ts`
|
|
||||||
3. **Update types** in `types.ts` if needed
|
|
||||||
4. **Run full test suite**: `bun test`
|
|
||||||
5. **Commit with conventional commits**: `feat: add QR generation`
|
|
||||||
|
|
||||||
## Feature Agenda
|
|
||||||
|
|
||||||
### 🚧 v1.2.0 - QR Code Round-Trip
|
|
||||||
|
|
||||||
**Goal**: Read back QR code and decrypt with user-provided credentials
|
|
||||||
|
|
||||||
**Tasks**:
|
|
||||||
- [ ] Add QR code generation from `encrypted.framed`
|
|
||||||
- Library: `qrcode` or `qr-code-styling`
|
|
||||||
- Input: SEEDPGP1 frame string
|
|
||||||
- Output: QR code image/canvas/SVG
|
|
||||||
|
|
||||||
- [ ] Add QR code scanner UI
|
|
||||||
- Library: `html5-qrcode` or `jsqr`
|
|
||||||
- Camera/file upload input
|
|
||||||
- Parse scanned text → `frameText`
|
|
||||||
|
|
||||||
- [ ] Build decrypt UI form
|
|
||||||
- Input fields:
|
|
||||||
- Scanned QR text (auto-filled)
|
|
||||||
- Private key (file upload or paste)
|
|
||||||
- Key passphrase (password input)
|
|
||||||
- OR message password (alternative)
|
|
||||||
- Call `decryptSeedPgp()`
|
|
||||||
- Display recovered mnemonic + metadata
|
|
||||||
|
|
||||||
- [ ] Add visual feedback
|
|
||||||
- CRC16 validation status
|
|
||||||
- Key fingerprint match indicator
|
|
||||||
- Decryption success/error states
|
|
||||||
|
|
||||||
**API Usage**:
|
|
||||||
```typescript
|
|
||||||
// Generate QR
|
|
||||||
import QRCode from 'qrcode';
|
|
||||||
const { framed } = await encryptToSeedPgp({ ... });
|
|
||||||
const qrDataUrl = await QRCode.toDataURL(framed);
|
|
||||||
|
|
||||||
// Scan and decrypt
|
|
||||||
const scannedText = "SEEDPGP1:0:ABCD:..."; // from scanner
|
|
||||||
const decrypted = await decryptSeedPgp({
|
|
||||||
frameText: scannedText,
|
|
||||||
privateKeyArmored: userKey,
|
|
||||||
privateKeyPassphrase: userPassword,
|
|
||||||
});
|
|
||||||
console.log(decrypted.w); // Recovered mnemonic
|
|
||||||
```
|
|
||||||
|
|
||||||
**Security Notes**:
|
|
||||||
- Never log decrypted mnemonics in production
|
|
||||||
- Clear sensitive data from memory after use
|
|
||||||
- Validate CRC16 before attempting decrypt
|
|
||||||
- Show key fingerprint for user verification
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔮 Future Ideas (v1.3+)
|
|
||||||
|
|
||||||
- [ ] Multi-frame support (for larger payloads)
|
|
||||||
- [ ] Password-only (SKESK) encryption flow
|
|
||||||
- [ ] Shamir Secret Sharing integration
|
|
||||||
- [ ] Hardware wallet key generation
|
|
||||||
- [ ] Mobile companion app (React Native)
|
|
||||||
- [ ] Printable paper backup templates
|
|
||||||
- [ ] Encrypted cloud backup with PBKDF2
|
|
||||||
- [ ] BIP85 child mnemonic derivation
|
|
||||||
|
|
||||||
## Debugging Tips
|
|
||||||
|
|
||||||
### Enable verbose PGP logging
|
|
||||||
|
|
||||||
Uncomment in `seedpgp.ts`:
|
|
||||||
```typescript
|
|
||||||
console.log("Raw PGP hex:", Array.from(pgpBytes).map(...));
|
|
||||||
console.log("SeedPGP: message packets:", ...);
|
|
||||||
console.log("SeedPGP: encryption key IDs:", ...);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test with known vectors
|
|
||||||
|
|
||||||
Use Trezor vectors from test file:
|
|
||||||
```bash
|
|
||||||
bun test "Trezor" # Run only Trezor tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Validate frame manually
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { frameParse } from "./lib/seedpgp";
|
|
||||||
const parsed = frameParse("SEEDPGP1:0:ABCD:...");
|
|
||||||
console.log(parsed); // Check structure
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
- **Functions**: Async by default, explicit return types
|
|
||||||
- **Errors**: Throw descriptive Error objects with context
|
|
||||||
- **Naming**: `camelCase` for functions, `PascalCase` for types
|
|
||||||
- **Comments**: Only for non-obvious crypto/encoding logic
|
|
||||||
- **Testing**: One test per edge case, descriptive names
|
|
||||||
|
|
||||||
## Git Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Feature branch
|
|
||||||
git checkout -b feat/qr-generation
|
|
||||||
|
|
||||||
# Conventional commits
|
|
||||||
git commit -m "feat(qr): add QR code generation"
|
|
||||||
git commit -m "test(qr): add QR round-trip test"
|
|
||||||
|
|
||||||
# Tag releases
|
|
||||||
git tag -a v1.2.0 -m "Release v1.2.0 - QR round-trip"
|
|
||||||
git push origin main --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
## Questions for Next Session
|
|
||||||
|
|
||||||
When continuing development, provide:
|
|
||||||
|
|
||||||
1. **Feature context**: "Adding QR code generation for v1.2.0"
|
|
||||||
2. **Current code**: Paste relevant files you're modifying
|
|
||||||
3. **Specific question**: "How should I structure the QR scanner component?"
|
|
||||||
|
|
||||||
Example starter prompt:
|
|
||||||
```
|
|
||||||
I'm working on seedpgp-web v1.1.0 (BIP39 PGP encryption tool).
|
|
||||||
|
|
||||||
[Paste this DEVELOPMENT.md section]
|
|
||||||
[Paste relevant source files]
|
|
||||||
|
|
||||||
I want to add QR code generation. Here's my current seedpgp.ts...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: 2026-01-28
|
|
||||||
**Maintainer**: @kccleoc
|
|
||||||
```
|
|
||||||
|
|
||||||
Now commit it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add DEVELOPMENT.md
|
|
||||||
git commit -m "docs: add development guide with v1.2.0 QR agenda"
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
Ready for your next feature sprint! 🚀📋
|
|
||||||
385
GEMINI.md
Normal file
385
GEMINI.md
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
# SeedPGP - Gemini Code Assist Project Brief
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**SeedPGP v1.4.5**: Client-side BIP39 mnemonic encryption webapp
|
||||||
|
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
|
||||||
|
**Deploy**: Cloudflare Pages (private repo: `seedpgp-web`)
|
||||||
|
**Live URL**: <https://seedpgp-web.pages.dev/>
|
||||||
|
|
||||||
|
## Core Constraints
|
||||||
|
|
||||||
|
1. **Security-first**: Never persist secrets (mnemonic/passphrase/private keys) to localStorage/sessionStorage/IndexedDB
|
||||||
|
2. **Small PRs**: Max 1-5 files per feature; propose plan before coding
|
||||||
|
3. **Client-side only**: No backend; all crypto runs in browser (Web Crypto API + OpenPGP.js)
|
||||||
|
4. **Honest security claims**: Don't overclaim what client-side JS can guarantee
|
||||||
|
|
||||||
|
## Non-Negotiables
|
||||||
|
|
||||||
|
- Small diffs only: one feature slice per PR (1-5 files if possible)
|
||||||
|
- No big code dumps; propose plan first, then implement
|
||||||
|
- Never persist secrets to browser storage
|
||||||
|
- Prefer "explain what you found in the repo" over guessing
|
||||||
|
- TypeScript strict mode; no `any` types without justification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Map
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
|
||||||
|
- `src/main.tsx` → `src/App.tsx` (main application)
|
||||||
|
- Build output: `dist/`
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```BASH
|
||||||
|
src/
|
||||||
|
├── components/ # React UI components
|
||||||
|
│ ├── PgpKeyInput.tsx
|
||||||
|
│ ├── QrDisplay.tsx
|
||||||
|
│ ├── QrScanner.tsx
|
||||||
|
│ ├── ReadOnly.tsx
|
||||||
|
│ ├── StorageIndicator.tsx
|
||||||
|
│ ├── SecurityWarnings.tsx
|
||||||
|
│ └── ClipboardTracker.tsx
|
||||||
|
├── lib/ # Core logic & crypto utilities
|
||||||
|
│ ├── seedpgp.ts # Main encrypt/decrypt functions
|
||||||
|
│ ├── sessionCrypto.ts # Ephemeral AES-GCM session keys
|
||||||
|
│ ├── types.ts # TypeScript interfaces
|
||||||
|
│ └── qr.ts # QR code utilities
|
||||||
|
├── App.tsx # Main app component
|
||||||
|
└── main.tsx # React entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Modules
|
||||||
|
|
||||||
|
#### `src/lib/seedpgp.ts`
|
||||||
|
|
||||||
|
Core encryption/decryption:
|
||||||
|
|
||||||
|
- `encryptToSeedPgp()` - Encrypts mnemonic with PGP public key + optional password
|
||||||
|
- `decryptFromSeedPgp()` - Decrypts with PGP private key + optional password
|
||||||
|
- Uses OpenPGP.js for PGP operations
|
||||||
|
- Output format: `SEEDPGP1:version:base64data:fingerprint`
|
||||||
|
|
||||||
|
#### `src/lib/sessionCrypto.ts` (v1.3.0+)
|
||||||
|
|
||||||
|
Ephemeral session-key encryption:
|
||||||
|
|
||||||
|
- `getSessionKey()` - Generates/returns non-exportable AES-GCM-256 key (idempotent)
|
||||||
|
- `encryptJsonToBlob(obj)` - Encrypts to `{v, alg, iv_b64, ct_b64}`
|
||||||
|
- `decryptBlobToJson(blob)` - Decrypts back to original object
|
||||||
|
- `destroySessionKey()` - Drops key reference for garbage collection
|
||||||
|
- Test: `await window.runSessionCryptoTest()` (DEV only)
|
||||||
|
|
||||||
|
#### `src/lib/types.ts`
|
||||||
|
|
||||||
|
Core interfaces:
|
||||||
|
|
||||||
|
- `SeedPgpPlaintext` - Decrypted mnemonic data structure
|
||||||
|
- `SeedPgpCiphertext` - Encrypted payload structure
|
||||||
|
- `EncryptedBlob` - Session-key encrypted cache format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### v1.0 - Core Functionality
|
||||||
|
|
||||||
|
- **Backup**: Encrypt mnemonic with PGP public key + optional password → QR display
|
||||||
|
- **Restore**: Scan/paste QR → decrypt with private key → show mnemonic
|
||||||
|
- **PGP support**: Import public/private keys (.asc files or paste)
|
||||||
|
|
||||||
|
### v1.1 - QR Features
|
||||||
|
|
||||||
|
- **QR Display**: Generate QR codes from encrypted data
|
||||||
|
- **QR Scanner**: Camera + file upload (uses html5-qrcode library)
|
||||||
|
|
||||||
|
### v1.2 - Security Monitoring
|
||||||
|
|
||||||
|
- **Storage Indicator**: Real-time display of localStorage/sessionStorage contents
|
||||||
|
- **Security Warnings**: Context-aware alerts about browser memory limitations
|
||||||
|
- **Clipboard Tracker**: Monitor clipboard operations on sensitive fields
|
||||||
|
- **Read-only Mode**: Toggle to clear state + show CSP/build info
|
||||||
|
|
||||||
|
### v1.3-v1.4 - Session-Key Encryption
|
||||||
|
|
||||||
|
- **Ephemeral encryption**: AES-GCM-256 session key (non-exportable) encrypts sensitive state
|
||||||
|
- **Backup flow (v1.3)**: Mnemonic auto-clears immediately after QR generation
|
||||||
|
- **Restore flow (v1.4)**: Decrypted mnemonic auto-clears after 10 seconds + manual Hide button
|
||||||
|
- **Encrypted cache**: Only ciphertext stored in React state; key lives in memory only
|
||||||
|
- **Lock/Clear**: Manual cleanup destroys session key + clears all state
|
||||||
|
- **Lifecycle**: Session key auto-destroyed on page close/refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install # Install dependencies
|
||||||
|
bun run dev # Dev server (localhost:5173)
|
||||||
|
bun run build # Build to dist/
|
||||||
|
bun run typecheck # TypeScript validation (tsc --noEmit)
|
||||||
|
bun run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment Process
|
||||||
|
|
||||||
|
**Production:** Cloudflare Pages (auto-deploys from `main` branch)
|
||||||
|
**Live URL:** <https://seedpgp-web.pages.dev>
|
||||||
|
|
||||||
|
### Cloudflare Pages Setup
|
||||||
|
|
||||||
|
1. **Repository:** `seedpgp-web` (private repo)
|
||||||
|
2. **Build command:** `bun run build`
|
||||||
|
3. **Output directory:** `dist/`
|
||||||
|
4. **Security headers:** Automatically enforced via `public/_headers`
|
||||||
|
|
||||||
|
### Git Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commit feature
|
||||||
|
git add src/
|
||||||
|
git commit -m "feat(v1.x): description"
|
||||||
|
|
||||||
|
# Push to main branch (including tags) triggers auto-deploy to Cloudflare
|
||||||
|
git tag v1.x.x
|
||||||
|
git push origin main --tags
|
||||||
|
|
||||||
|
# **IMPORTANT: Update README.md before tagging**
|
||||||
|
# Update the following sections in README.md:
|
||||||
|
# - Current version number in header
|
||||||
|
# - Recent Changes section with new features
|
||||||
|
# - Any new usage instructions or screenshots
|
||||||
|
# Then commit the README update:
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: update README for v1.x.x"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Workflow for AI Agents
|
||||||
|
|
||||||
|
### 1. Study First
|
||||||
|
|
||||||
|
Before implementing any feature:
|
||||||
|
|
||||||
|
- Read relevant files
|
||||||
|
- Explain current architecture + entry points
|
||||||
|
- List files that will be touched
|
||||||
|
- Identify potential conflicts or dependencies
|
||||||
|
|
||||||
|
### 2. Plan
|
||||||
|
|
||||||
|
- Propose smallest vertical slice (1-5 files)
|
||||||
|
- Show API signatures or interface changes first
|
||||||
|
- Get approval before generating full implementation
|
||||||
|
|
||||||
|
### 3. Implement
|
||||||
|
|
||||||
|
- Generate code with TypeScript strict mode
|
||||||
|
- Include JSDoc comments for public APIs
|
||||||
|
- Show unified diffs, not full file rewrites (when possible)
|
||||||
|
- Keep changes under 50-100 lines per file when feasible
|
||||||
|
|
||||||
|
### 4. Verify
|
||||||
|
|
||||||
|
- Run `bun run typecheck` - no errors
|
||||||
|
- Run `bun run build` - successful dist/ output
|
||||||
|
- Provide manual test steps for browser verification
|
||||||
|
- Show build output / console logs / DevTools screenshots
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- React `useState` + `useEffect` (no Redux/Zustand/external store)
|
||||||
|
- Ephemeral state only; avoid persistent storage for secrets
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- Tailwind utility classes (configured in `tailwind.config.js`)
|
||||||
|
- Responsive design: mobile-first with `md:` breakpoints
|
||||||
|
- Dark theme primary: slate-900 background, blue-400 accents
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
- `lucide-react` library
|
||||||
|
- Common: Shield, QrCode, Lock, Eye, AlertCircle
|
||||||
|
|
||||||
|
### Crypto Operations
|
||||||
|
|
||||||
|
- **PGP**: OpenPGP.js (`openpgp` package)
|
||||||
|
- **Session keys**: Web Crypto API (`crypto.subtle`)
|
||||||
|
- **Key generation**: `crypto.subtle.generateKey()` with `extractable: false`
|
||||||
|
- **Encryption**: AES-GCM with random 12-byte IV per operation
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
|
||||||
|
- Strict TypeScript (`tsconfig.json`: `strict: true`)
|
||||||
|
- Check `src/lib/types.ts` for core interfaces
|
||||||
|
- Avoid `any`; use `unknown` + type guards when necessary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Architecture
|
||||||
|
|
||||||
|
### Threat Model (Honest)
|
||||||
|
|
||||||
|
**What we protect against:**
|
||||||
|
|
||||||
|
- Accidental persistence to localStorage/sessionStorage
|
||||||
|
- Plaintext secrets lingering in React state after use
|
||||||
|
- Clipboard history exposure (with warnings)
|
||||||
|
|
||||||
|
**What we DON'T protect against (and must not claim to):**
|
||||||
|
|
||||||
|
- Active XSS or malicious browser extensions
|
||||||
|
- Memory dumps or browser crash reports
|
||||||
|
- JavaScript garbage collection timing (non-deterministic)
|
||||||
|
|
||||||
|
### Memory Handling
|
||||||
|
|
||||||
|
- **Session keys**: Non-exportable CryptoKey objects (Web Crypto API)
|
||||||
|
- **Plaintext clearing**: Set to empty string + drop references (but GC timing is non-deterministic)
|
||||||
|
- **No guarantees**: Cannot force immediate memory wiping in JavaScript
|
||||||
|
|
||||||
|
### Storage Policy
|
||||||
|
|
||||||
|
- **NEVER write to**: localStorage, sessionStorage, IndexedDB, cookies
|
||||||
|
- **Exception**: Non-sensitive UI state only (theme preferences, etc.) - NOT IMPLEMENTED YET
|
||||||
|
- **Verification**: StorageIndicator component monitors all storage APIs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What NOT to Do
|
||||||
|
|
||||||
|
### Code Generation
|
||||||
|
|
||||||
|
- Don't generate full file rewrites unless necessary
|
||||||
|
- Don't add dependencies without discussing bundle size impact
|
||||||
|
- Don't use `any` types without explicit justification
|
||||||
|
- Don't skip TypeScript strict mode checks
|
||||||
|
|
||||||
|
### Security Claims
|
||||||
|
|
||||||
|
- Don't claim "RAM is wiped" (JavaScript can't force GC)
|
||||||
|
- Don't promise protection against active browser compromise (XSS/extensions)
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
- Don't write secrets to storage without explicit approval
|
||||||
|
- Don't cache decrypted data beyond immediate use
|
||||||
|
- Don't assume browser storage is secure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Verification
|
||||||
|
|
||||||
|
### Manual Test Checklist (Before Marking Feature Complete)
|
||||||
|
|
||||||
|
1. ✅ `bun run typecheck` passes (no TypeScript errors)
|
||||||
|
2. ✅ `bun run build` succeeds (dist/ generated)
|
||||||
|
3. ✅ Browser test: Feature works as described
|
||||||
|
4. ✅ DevTools Console: No runtime errors
|
||||||
|
5. ✅ DevTools Application tab: No plaintext secrets in storage
|
||||||
|
6. ✅ DevTools Network tab: No unexpected network calls (if Read-only Mode)
|
||||||
|
|
||||||
|
### Session-Key Encryption Test (v1.3+)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In browser DevTools console:
|
||||||
|
await window.runSessionCryptoTest()
|
||||||
|
// Expected: ✅ Success: Data integrity verified.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Version: v1.4.5
|
||||||
|
|
||||||
|
**Recent Changes (v1.4.5):**
|
||||||
|
- Fixed QR Scanner bugs related to camera initialization and race conditions.
|
||||||
|
- Improved error handling in the scanner to prevent crashes and provide better feedback.
|
||||||
|
- Stabilized component props to prevent unnecessary re-renders and fix `AbortError`.
|
||||||
|
|
||||||
|
**Known Limitations (Critical):**
|
||||||
|
1. **Browser extensions** can read DOM, memory, keystrokes - use dedicated browser
|
||||||
|
2. **Memory persistence** - JavaScript cannot force immediate memory wiping
|
||||||
|
3. **XSS attacks** if hosting server is compromised - host locally
|
||||||
|
4. **Hardware keyloggers** - physical device compromise not protected against
|
||||||
|
5. **Supply chain attacks** - compromised dependencies possible
|
||||||
|
6. **Quantum computers** - future threat to current cryptography
|
||||||
|
|
||||||
|
**Next Priorities:**
|
||||||
|
1. Enhanced BIP39 validation (full wordlist + checksum)
|
||||||
|
2. Multi-frame support for larger payloads
|
||||||
|
3. Hardware wallet integration (Trezor/Keystone)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### File a Bug/Feature
|
||||||
|
|
||||||
|
1. Describe expected vs actual behavior
|
||||||
|
2. Include browser console errors (if any)
|
||||||
|
3. Specify which flow (Backup/Restore/QR Scanner)
|
||||||
|
|
||||||
|
### Roll Over to Next Session
|
||||||
|
|
||||||
|
Always provide:
|
||||||
|
|
||||||
|
- Current version number
|
||||||
|
- What was implemented this session
|
||||||
|
- Files modified
|
||||||
|
- What still needs work
|
||||||
|
- Any gotchas or edge cases discovered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Prompts for Gemini
|
||||||
|
|
||||||
|
### Exploration
|
||||||
|
|
||||||
|
```
|
||||||
|
Read GEMINI.md, then explain:
|
||||||
|
1. Where is the mnemonic textarea and how is its value managed?
|
||||||
|
2. List all places localStorage/sessionStorage are used
|
||||||
|
3. Show data flow from "Backup" button to QR display
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Request
|
||||||
|
|
||||||
|
```
|
||||||
|
Task: [Feature description]
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
1. [Specific requirement]
|
||||||
|
2. [Another requirement]
|
||||||
|
|
||||||
|
Files to touch:
|
||||||
|
- [List files]
|
||||||
|
|
||||||
|
Plan first: show proposed API/changes before generating code.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```
|
||||||
|
Audit the codebase to verify [feature] is fully implemented.
|
||||||
|
Check:
|
||||||
|
1. [Requirement 1]
|
||||||
|
2. [Requirement 2]
|
||||||
|
Output: ✅ or ❌ for each item + suggest fixes for failures.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-01-29
|
||||||
|
**Maintained by**: @kccleoc
|
||||||
|
**AI Agent**: Optimized for Gemini Code Assist
|
||||||
659
README.md
659
README.md
@@ -1,26 +1,204 @@
|
|||||||
# SeedPGP v1.1.0
|
# SeedPGP v1.4.5
|
||||||
|
|
||||||
**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,258 @@ 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.5 (2026-02-07)
|
||||||
|
- ✅ **Fixed QR Scanner bugs** related to camera initialization and race conditions.
|
||||||
|
- ✅ **Improved error handling** in the scanner to prevent crashes and provide better feedback.
|
||||||
|
- ✅ **Stabilized component props** to prevent unnecessary re-renders and fix `AbortError`.
|
||||||
|
|
||||||
- [ ] QR code generation UI
|
### v1.4.4 (2026-02-03)
|
||||||
- [ ] QR code scanner with camera support
|
- ✅ **Enhanced security documentation** with explicit threat model
|
||||||
- [ ] Multi-frame support for larger payloads
|
- ✅ **Improved README** with simple examples and best practices
|
||||||
- [ ] Hardware wallet integration
|
- ✅ **Better air-gapped usage guidance** for maximum security
|
||||||
- [ ] Mobile scanning app
|
- ✅ **Version bump** with security audit improvements
|
||||||
- [ ] Shamir Secret Sharing support
|
|
||||||
|
|
||||||
## License
|
### v1.4.3 (2026-01-30)
|
||||||
|
- ✅ Fixed textarea contrast for readability
|
||||||
|
- ✅ Fixed overlapping floating boxes
|
||||||
|
- ✅ Polished UI with modern crypto wallet design
|
||||||
|
|
||||||
MIT License - see LICENSE file for details
|
### v1.4.2 (2026-01-30)
|
||||||
|
- ✅ Migrated to Cloudflare Pages for real CSP enforcement
|
||||||
|
- ✅ Added "Encrypted in memory" badge
|
||||||
|
- ✅ Improved security header configuration
|
||||||
|
|
||||||
## Author
|
### 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
|
||||||
|
|
||||||
**kccleoc** - [GitHub](https://github.com/kccleoc)
|
[View full version history...](https://github.com/kccleoc/seedpgp-web/releases)
|
||||||
|
|
||||||
## 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
422
RECOVERY_PLAYBOOK.md
Normal 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
|
||||||
|
|
||||||
|
***
|
||||||
BIN
Screenshot 2026-02-08 at 21.14.14.png
Normal file
BIN
Screenshot 2026-02-08 at 21.14.14.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
108
bun.lock
108
bun.lock
@@ -5,25 +5,37 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "seedpgp-web",
|
"name": "seedpgp-web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/bip32": "^2.0.4",
|
||||||
|
"@types/bip39": "^3.0.4",
|
||||||
|
"@types/pako": "^2.0.4",
|
||||||
|
"bip32": "^5.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"openpgp": "^6.3.0",
|
"openpgp": "^6.3.0",
|
||||||
|
"pako": "^2.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"tiny-secp256k1": "^2.2.4",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.6",
|
"@types/bun": "^1.3.6",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^25.2.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/qrcode-generator": "^1.0.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -130,6 +142,8 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
@@ -138,6 +152,8 @@
|
|||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg=="],
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg=="],
|
||||||
@@ -188,6 +204,36 @@
|
|||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="],
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="],
|
||||||
|
|
||||||
|
"@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
||||||
|
|
||||||
|
"@swc/core": ["@swc/core@1.15.11", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.11", "@swc/core-darwin-x64": "1.15.11", "@swc/core-linux-arm-gnueabihf": "1.15.11", "@swc/core-linux-arm64-gnu": "1.15.11", "@swc/core-linux-arm64-musl": "1.15.11", "@swc/core-linux-x64-gnu": "1.15.11", "@swc/core-linux-x64-musl": "1.15.11", "@swc/core-win32-arm64-msvc": "1.15.11", "@swc/core-win32-ia32-msvc": "1.15.11", "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w=="],
|
||||||
|
|
||||||
|
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg=="],
|
||||||
|
|
||||||
|
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.11", "", { "os": "linux", "cpu": "arm" }, "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.11", "", { "os": "linux", "cpu": "x64" }, "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.11", "", { "os": "win32", "cpu": "x64" }, "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw=="],
|
||||||
|
|
||||||
|
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||||
|
|
||||||
|
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
|
||||||
|
|
||||||
|
"@swc/wasm": ["@swc/wasm@1.15.11", "", {}, "sha512-230rdYZf8ux3nIwISOQNCFrxzxpL/UFY4Khv/3UsvpEdo709j/+Tg80yXWW3DXETeZNPBV72QpvEBhXsl7Lc9g=="],
|
||||||
|
|
||||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
@@ -196,20 +242,30 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
|
"@types/bip32": ["@types/bip32@2.0.4", "", { "dependencies": { "bip32": "*" } }, "sha512-5VE8jtDlFx94IyopdhtkcZ/6oCSvpLS1yOcwkUgi9/zwL9LG99q4+nYv6N/HntPGqB9wcE6osxrtmErt75sjxA=="],
|
||||||
|
|
||||||
|
"@types/bip39": ["@types/bip39@3.0.4", "", { "dependencies": { "bip39": "*" } }, "sha512-kgmgxd14vTUMqcKu/gRi7adMchm7teKnOzdkeP0oQ5QovXpbUJISU0KUtBt84DdxCws/YuNlSCIoZqgXexe6KQ=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
|
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
|
"@types/node": ["@types/node@25.2.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg=="],
|
||||||
|
|
||||||
|
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||||
|
|
||||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||||
|
|
||||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||||
|
|
||||||
|
"@types/qrcode-generator": ["@types/qrcode-generator@1.0.6", "", { "dependencies": { "qrcode-generator": "*" } }, "sha512-XasuPjhHBC4hyOJ/pHaUNTj+tNxA1SyZpXaS/FOUxEVX03D1gFM8UmMKSIs+pPHLAmRZpU6j9KYxvo+lfsvhKw=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="],
|
"@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@1.2.0", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" } }, "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q=="],
|
||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
@@ -224,14 +280,28 @@
|
|||||||
|
|
||||||
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
||||||
|
|
||||||
|
"base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
|
"bip32": ["bip32@5.0.0", "", { "dependencies": { "@noble/hashes": "^1.2.0", "@scure/base": "^1.1.1", "uint8array-tools": "^0.0.8", "valibot": "^0.37.0", "wif": "^5.0.0" } }, "sha512-h043yQ9n3iU4WZ8KLRpEECMl3j1yx2DQ1kcPlzWg8VZC0PtukbDiyLDKbe6Jm79mL6Tfg+WFuZMYxnzVyr/Hyw=="],
|
||||||
|
|
||||||
|
"bip39": ["bip39@3.1.0", "", { "dependencies": { "@noble/hashes": "^1.2.0" } }, "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A=="],
|
||||||
|
|
||||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
|
"bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw=="],
|
||||||
|
|
||||||
|
"bs58check": ["bs58check@4.0.0", "", { "dependencies": { "@noble/hashes": "^1.2.0", "bs58": "^6.0.0" } }, "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g=="],
|
||||||
|
|
||||||
|
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
||||||
|
|
||||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
@@ -300,6 +370,8 @@
|
|||||||
|
|
||||||
"html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="],
|
"html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
@@ -320,6 +392,8 @@
|
|||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
|
||||||
|
|
||||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
@@ -358,6 +432,8 @@
|
|||||||
|
|
||||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
|
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
@@ -388,6 +464,8 @@
|
|||||||
|
|
||||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||||
|
|
||||||
|
"qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="],
|
||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||||
@@ -434,6 +512,8 @@
|
|||||||
|
|
||||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
|
|
||||||
|
"tiny-secp256k1": ["tiny-secp256k1@2.2.4", "", { "dependencies": { "uint8array-tools": "0.0.7" } }, "sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
@@ -442,16 +522,28 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"uint8array-tools": ["uint8array-tools@0.0.8", "", {}, "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||||
|
|
||||||
|
"valibot": ["valibot@0.37.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-FQz52I8RXgFgOHym3XHYSREbNtkgSjF9prvMFH1nBsRyfL6SfCzoT1GuSDTlbsuPubM7/6Kbw0ZMQb8A+V+VsQ=="],
|
||||||
|
|
||||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||||
|
|
||||||
|
"vite-plugin-top-level-await": ["vite-plugin-top-level-await@1.6.0", "", { "dependencies": { "@rollup/plugin-virtual": "^3.0.2", "@swc/core": "^1.12.14", "@swc/wasm": "^1.12.14", "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww=="],
|
||||||
|
|
||||||
|
"vite-plugin-wasm": ["vite-plugin-wasm@3.5.0", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ=="],
|
||||||
|
|
||||||
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||||
|
|
||||||
|
"wif": ["wif@5.0.0", "", { "dependencies": { "bs58check": "^4.0.0" } }, "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||||
@@ -462,8 +554,12 @@
|
|||||||
|
|
||||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||||
|
|
||||||
|
"@types/qrcode/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
|
||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"bun-types/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -471,5 +567,11 @@
|
|||||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"tiny-secp256k1/uint8array-tools": ["uint8array-tools@0.0.7", "", {}, "sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ=="],
|
||||||
|
|
||||||
|
"@types/qrcode/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
debug_krux.py
Normal file
71
debug_krux.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#
|
||||||
|
# This is a debug script to trace the Krux decryption process.
|
||||||
|
# It has been modified to be self-contained and avoid MicroPython-specific libraries.
|
||||||
|
#
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add the source directory to the path to allow imports
|
||||||
|
sys.path.append('REFERENCE/krux/src')
|
||||||
|
|
||||||
|
from krux.baseconv import pure_python_base_decode
|
||||||
|
|
||||||
|
def unwrap_standalone(kef_bytes):
|
||||||
|
"""A self-contained version of kef.unwrap for debugging."""
|
||||||
|
try:
|
||||||
|
len_id = kef_bytes[0]
|
||||||
|
if not (0 <= len_id <= 252):
|
||||||
|
raise ValueError(f"Invalid label length: {len_id}")
|
||||||
|
if len(kef_bytes) < (1 + len_id + 4):
|
||||||
|
raise ValueError("KEF bytes too short for header")
|
||||||
|
|
||||||
|
id_ = kef_bytes[1 : 1 + len_id]
|
||||||
|
version = kef_bytes[1 + len_id]
|
||||||
|
kef_iterations = int.from_bytes(kef_bytes[2 + len_id : 5 + len_id], "big")
|
||||||
|
|
||||||
|
if kef_iterations <= 10000:
|
||||||
|
iterations = kef_iterations * 10000
|
||||||
|
else:
|
||||||
|
iterations = kef_iterations
|
||||||
|
|
||||||
|
payload = kef_bytes[len_id + 5 :]
|
||||||
|
return (id_, version, iterations, payload)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to unwrap KEF envelope: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def debug_krux_decryption():
|
||||||
|
# Test case from the user
|
||||||
|
base43_string = "1334+HGXM$F8PPOIRNHX0.R*:SBMHK$X88LX$*/Y417R/6S1ZQOB2LHM-L+4T1YQVU:B*CKGXONP7:Y/R-B*:$R8FK"
|
||||||
|
|
||||||
|
print("--- Krux Decryption Debug (Phase 1: Decoding & Unwrapping) ---")
|
||||||
|
print(f"Input Base43: {base43_string}\n")
|
||||||
|
|
||||||
|
# Step 1: Base43 Decode
|
||||||
|
try:
|
||||||
|
kef_envelope_bytes = pure_python_base_decode(base43_string, 43)
|
||||||
|
print(f"[OK] Step 1: Base43 Decoded (KEF Envelope Hex):")
|
||||||
|
print(kef_envelope_bytes.hex())
|
||||||
|
print("-" * 20)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[FAIL] Step 1: Base43 Decoding failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 2: Unwrap KEF Envelope
|
||||||
|
try:
|
||||||
|
label_bytes, version, iterations, payload = unwrap_standalone(kef_envelope_bytes)
|
||||||
|
label = label_bytes.decode('utf-8', errors='ignore')
|
||||||
|
print(f"[OK] Step 2: KEF Unwrapped")
|
||||||
|
print(f" - Label: '{label}'")
|
||||||
|
print(f" - Version: {version}")
|
||||||
|
print(f" - Iterations: {iterations}")
|
||||||
|
print(f" - Payload (Hex): {payload.hex()}")
|
||||||
|
print("-" * 20)
|
||||||
|
print("\n--- DEBUGGING COMPLETE ---")
|
||||||
|
print("Please paste this entire output for analysis.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[FAIL] Step 2: KEF Unwrapping failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
debug_krux_decryption()
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SeedPGP v1.1</title>
|
<title>SeedPGP v__APP_VERSION__</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -1,32 +1,45 @@
|
|||||||
{
|
{
|
||||||
"name": "seedpgp-web",
|
"name": "seedpgp-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.4.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/bip32": "^2.0.4",
|
||||||
|
"@types/bip39": "^3.0.4",
|
||||||
|
"@types/pako": "^2.0.4",
|
||||||
|
"bip32": "^5.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"openpgp": "^6.3.0",
|
"openpgp": "^6.3.0",
|
||||||
|
"pako": "^2.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"tiny-secp256k1": "^2.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.6",
|
"@types/bun": "^1.3.6",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^25.2.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/qrcode-generator": "^1.0.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^6.0.3"
|
"vite": "^6.0.3",
|
||||||
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
|
"vite-plugin-wasm": "^3.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# SeedPGP Web App
|
|
||||||
|
|
||||||
**Secure BIP39 mnemonic backup tool using PGP encryption**
|
|
||||||
|
|
||||||
🔗 **Live App**: https://kccleoc.github.io/seedpgp-web-app/
|
|
||||||
|
|
||||||
## About
|
|
||||||
|
|
||||||
This is a client-side web application for encrypting cryptocurrency seed phrases (BIP39 mnemonics) using OpenPGP encryption and Base45 encoding for QR code generation.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- 🔐 PGP encryption with cv25519 (Curve25519)
|
|
||||||
- 📱 QR code-ready output (Base45 + CRC16)
|
|
||||||
- ✅ Supports 12/18/24-word BIP39 mnemonics
|
|
||||||
- 🔒 All encryption happens in your browser (client-side only)
|
|
||||||
|
|
||||||
## Security Notice
|
|
||||||
|
|
||||||
⚠️ **Your private keys and seed phrases never leave your browser**
|
|
||||||
|
|
||||||
- This is a static web app with no backend server
|
|
||||||
- All cryptographic operations run locally in your browser
|
|
||||||
- No data is transmitted to any server
|
|
||||||
- Always verify you're on the correct URL before use
|
|
||||||
|
|
||||||
### For Maximum Security
|
|
||||||
|
|
||||||
For production use with real funds:
|
|
||||||
- Download and run locally
|
|
||||||
- Or use a self-hosted version
|
|
||||||
- Source code: https://github.com/kccleoc/seedpgp-web
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
1. **Encrypt**: Enter your mnemonic + PGP public key → Get QR code
|
|
||||||
2. **Decrypt**: Scan QR code + provide private key → Recover mnemonic
|
|
||||||
|
|
||||||
## Version
|
|
||||||
|
|
||||||
Current deployment: **v1.1.0**
|
|
||||||
|
|
||||||
Last updated: 2026-01-28
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Built with TypeScript, React, Vite, and OpenPGP.js v6
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
VERSION=$1
|
|
||||||
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
echo "Usage: ./scripts/deploy.sh v1.2.0"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🔨 Building $VERSION..."
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
echo "📄 Adding README..."
|
|
||||||
cp public/README.md dist/README.md
|
|
||||||
|
|
||||||
echo "📦 Deploying to GitHub Pages..."
|
|
||||||
cd dist
|
|
||||||
git add .
|
|
||||||
git commit -m "Deploy $VERSION" || echo "No changes to commit"
|
|
||||||
git push
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
|
|
||||||
echo "📖 Repo: https://github.com/kccleoc/seedpgp-web-app"
|
|
||||||
echo "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
|
|
||||||
echo "./scripts/deploy.sh v1.2.0"
|
|
||||||
echo "git tag v1.2.0"
|
|
||||||
echo "git push --tags"
|
|
||||||
|
|
||||||
|
|
||||||
1101
src/App.tsx
1101
src/App.tsx
File diff suppressed because it is too large
Load Diff
2048
src/bip39_wordlist.txt
Normal file
2048
src/bip39_wordlist.txt
Normal file
File diff suppressed because it is too large
Load Diff
62
src/components/ClipboardDetails.tsx
Normal file
62
src/components/ClipboardDetails.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ClipboardEvent {
|
||||||
|
timestamp: Date;
|
||||||
|
field: string;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClipboardDetailsProps {
|
||||||
|
events: ClipboardEvent[];
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClipboardDetails: React.FC<ClipboardDetailsProps> = ({ events, onClear }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div className="mb-3 bg-orange-100 border border-orange-300 rounded-md p-3 text-xs text-orange-900">
|
||||||
|
<strong>⚠️ Clipboard Warning:</strong> Copied data is accessible to other apps,
|
||||||
|
browser tabs, and extensions. Clear clipboard after use.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{events.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 mb-3 max-h-64 overflow-y-auto">
|
||||||
|
{events.map((event, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-orange-200 rounded-md p-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-orange-900 break-all">
|
||||||
|
{event.field}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-[10px] whitespace-nowrap">
|
||||||
|
{event.timestamp.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-[10px]">
|
||||||
|
Copied {event.length} character{event.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="w-full text-xs py-2 px-3 bg-orange-600 hover:bg-orange-700 text-white rounded-md font-medium transition-colors"
|
||||||
|
>
|
||||||
|
🗑️ Clear Clipboard & History
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="text-3xl mb-2">✅</div>
|
||||||
|
<p className="text-xs text-gray-500">No clipboard activity detected</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
src/components/Footer.tsx
Normal file
18
src/components/Footer.tsx
Normal 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-[#6ef3f7] 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;
|
||||||
173
src/components/Header.tsx
Normal file
173
src/components/Header.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Shield, RefreshCw, Lock, Unlock } 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: 'create' | 'backup' | 'restore' | 'seedblender';
|
||||||
|
onRequestTabChange: (tab: 'create' | 'backup' | 'restore' | 'seedblender') => void;
|
||||||
|
encryptedMnemonicCache: any;
|
||||||
|
handleLockAndClear: () => void;
|
||||||
|
appVersion: string;
|
||||||
|
isLocked: boolean;
|
||||||
|
onToggleLock: () => void;
|
||||||
|
onResetAll: () => void; // NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<HeaderProps> = ({
|
||||||
|
onOpenSecurityModal,
|
||||||
|
onOpenStorageModal,
|
||||||
|
localItems,
|
||||||
|
sessionItems,
|
||||||
|
events,
|
||||||
|
onOpenClipboardModal,
|
||||||
|
activeTab,
|
||||||
|
onRequestTabChange,
|
||||||
|
encryptedMnemonicCache,
|
||||||
|
handleLockAndClear,
|
||||||
|
appVersion,
|
||||||
|
isLocked,
|
||||||
|
onToggleLock,
|
||||||
|
onResetAll
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 bg-[#0a0a0f] border-b border-[#00f0ff30] backdrop-blur-sm">
|
||||||
|
<div className="w-full px-4 py-3 space-y-3">
|
||||||
|
|
||||||
|
{/* ROW 1: Logo + App Info */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-[#00f0ff] rounded-lg flex items-center justify-center shadow-[0_0_15px_rgba(0,240,255,0.5)]">
|
||||||
|
<Shield className="w-6 h-6 text-[#0a0a0f]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
||||||
|
SeedPGP <span className="text-[#ff006e]">{appVersion}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-[#6ef3f7]">OpenPGP-secured BIP39 backup</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ROW 2: Monitoring Badges + Action Buttons */}
|
||||||
|
<div className="flex items-center justify-between gap-2 pb-2 border-b border-[#00f0ff20]">
|
||||||
|
{/* Left: Badges */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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-2">
|
||||||
|
{encryptedMnemonicCache && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleLock}
|
||||||
|
className="px-2 py-1.5 text-base bg-[#16213e] border border-[#00f0ff] text-[#00f0ff] rounded-lg font-medium hover:bg-[#00f0ff20] transition-all"
|
||||||
|
title={isLocked ? "Show sensitive data" : "Hide sensitive data"}
|
||||||
|
>
|
||||||
|
{isLocked ? '🔓' : '🙈'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText('');
|
||||||
|
} catch { }
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
}}
|
||||||
|
className="px-2 py-1.5 text-base bg-[#16213e] border border-[#00f0ff] text-[#00f0ff] rounded-lg font-medium hover:bg-[#00f0ff20] transition-all"
|
||||||
|
title="Clear clipboard and storage"
|
||||||
|
>
|
||||||
|
📋
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onResetAll}
|
||||||
|
className="px-2 py-1.5 text-xs bg-[#16213e] border border-[#ff006e] text-[#ff006e] rounded-lg font-medium hover:bg-[#ff006e20] transition-all whitespace-nowrap flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ROW 3: Navigation Tabs */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<button
|
||||||
|
className={`py-2 rounded-lg font-medium text-xs whitespace-nowrap transition-all ${activeTab === 'create'
|
||||||
|
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
|
||||||
|
: 'bg-[#0a0a0f] text-[#9d84b7] border-2 border-[#00f0ff30] hover:text-[#6ef3f7] hover:border-[#00f0ff50]'
|
||||||
|
}`}
|
||||||
|
style={activeTab === 'create' ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}
|
||||||
|
onClick={() => onRequestTabChange('create')}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`py-2 rounded-lg font-medium text-xs whitespace-nowrap transition-all ${activeTab === 'backup'
|
||||||
|
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
|
||||||
|
: 'bg-[#0a0a0f] text-[#9d84b7] border-2 border-[#00f0ff30] hover:text-[#6ef3f7] hover:border-[#00f0ff50]'
|
||||||
|
}`}
|
||||||
|
style={activeTab === 'backup' ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}
|
||||||
|
onClick={() => onRequestTabChange('backup')}
|
||||||
|
>
|
||||||
|
Backup
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`py-2 rounded-lg font-medium text-xs whitespace-nowrap transition-all ${activeTab === 'restore'
|
||||||
|
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
|
||||||
|
: 'bg-[#0a0a0f] text-[#9d84b7] border-2 border-[#00f0ff30] hover:text-[#6ef3f7] hover:border-[#00f0ff50]'
|
||||||
|
}`}
|
||||||
|
style={activeTab === 'restore' ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}
|
||||||
|
onClick={() => onRequestTabChange('restore')}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`py-2 rounded-lg font-medium text-xs whitespace-nowrap transition-all ${activeTab === 'seedblender'
|
||||||
|
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
|
||||||
|
: 'bg-[#0a0a0f] text-[#9d84b7] border-2 border-[#00f0ff30] hover:text-[#6ef3f7] hover:border-[#00f0ff50]'
|
||||||
|
}`}
|
||||||
|
style={activeTab === 'seedblender' ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}
|
||||||
|
onClick={() => onRequestTabChange('seedblender')}
|
||||||
|
>
|
||||||
|
Blender
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Upload } from 'lucide-react';
|
import { Upload } from 'lucide-react';
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
interface PgpKeyInputProps {
|
interface PgpKeyInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
||||||
@@ -17,21 +16,25 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
label,
|
label,
|
||||||
icon: Icon
|
icon: Icon,
|
||||||
|
readOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
if (readOnly) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
if (readOnly) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
if (readOnly) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
@@ -49,30 +52,33 @@ 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-[12px] font-bold text-[#00f0ff] uppercase tracking-widest flex items-center justify-between" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{Icon && <Icon size={14} />} {label}
|
{Icon && <Icon size={14} />} {label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
|
{!readOnly && (
|
||||||
|
<span className="text-[10px] text-[#6ef3f7] font-normal bg-[#16213e] px-2 py-0.5 rounded-full border border-[#00f0ff]/30">
|
||||||
Drag & Drop .asc file
|
Drag & Drop .asc file
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
className={`relative transition-all duration-200 ${isDragging ? 'scale-[1.01]' : ''}`}
|
className={`relative transition-all duration-200 ${isDragging && !readOnly ? 'scale-[1.01]' : ''}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 ${isDragging ? 'border-blue-500 bg-blue-50' : 'border-slate-200'
|
className={`w-full h-40 p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-xl text-xs font-mono text-[#00f0ff] placeholder-[#9d84b7] transition-colors resize-none focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] ${isDragging && !readOnly ? 'border-[#ff006e] bg-[#16213e]' : 'border-[#00f0ff]/50'} ${readOnly ? 'blur-sm select-none' : ''
|
||||||
}`}
|
}`}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
{isDragging && (
|
{isDragging && !readOnly && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-50/90 rounded-xl border-2 border-dashed border-blue-500 pointer-events-none z-10">
|
<div className="absolute inset-0 flex items-center justify-center bg-[#16213e]/90 rounded-xl border-2 border-dashed border-[#ff006e] pointer-events-none z-10">
|
||||||
<div className="text-blue-600 font-bold flex flex-col items-center animate-bounce">
|
<div className="text-[#ff006e] font-bold flex flex-col items-center animate-bounce" style={{ textShadow: '0 0 10px rgba(255,0,110,0.5)' }}>
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,223 +1,173 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Camera, Upload, X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
|
import { Camera, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { Html5Qrcode } from 'html5-qrcode';
|
import jsQR from 'jsqr';
|
||||||
|
|
||||||
interface QRScannerProps {
|
interface QRScannerProps {
|
||||||
onScanSuccess: (scannedText: string) => void;
|
onScanSuccess: (data: string | Uint8Array) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
|
export default function QRScanner({ onScanSuccess, onClose, onError }: QRScannerProps) {
|
||||||
const [scanMode, setScanMode] = useState<'camera' | 'file' | null>(null);
|
const [internalError, setInternalError] = useState<string>('');
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
|
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const startCamera = async () => {
|
useEffect(() => {
|
||||||
setError('');
|
let stream: MediaStream | null = null;
|
||||||
setScanMode('camera');
|
let scanInterval: number | null = null;
|
||||||
setScanning(true);
|
let isCancelled = false;
|
||||||
|
|
||||||
// Wait for DOM to render the #qr-reader div
|
const stopScanning = () => {
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
if (scanInterval) clearInterval(scanInterval);
|
||||||
|
if (stream) stream.getTracks().forEach(track => track.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
const startScanning = async () => {
|
||||||
try {
|
try {
|
||||||
// Check if we're on HTTPS or localhost
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
if (window.location.protocol !== 'https:' && !window.location.hostname.includes('localhost')) {
|
video: { facingMode: 'environment' }
|
||||||
throw new Error('Camera requires HTTPS or localhost. Use: bun run dev');
|
});
|
||||||
|
|
||||||
|
if (isCancelled) return stopScanning();
|
||||||
|
|
||||||
|
setHasPermission(true);
|
||||||
|
const video = videoRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
|
if (video && canvas) {
|
||||||
|
video.srcObject = stream;
|
||||||
|
await video.play();
|
||||||
|
|
||||||
|
if (isCancelled) return stopScanning();
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
setInternalError('Canvas context not available');
|
||||||
|
return stopScanning();
|
||||||
}
|
}
|
||||||
|
|
||||||
const html5QrCode = new Html5Qrcode('qr-reader');
|
scanInterval = window.setInterval(() => {
|
||||||
html5QrCodeRef.current = html5QrCode;
|
if (isCancelled || !video || video.paused || video.ended) return;
|
||||||
|
if (!video.videoWidth || !video.videoHeight) return;
|
||||||
|
|
||||||
await html5QrCode.start(
|
canvas.width = video.videoWidth;
|
||||||
{ facingMode: 'environment' },
|
canvas.height = video.videoHeight;
|
||||||
{
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
fps: 10,
|
|
||||||
qrbox: { width: 250, height: 250 },
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
aspectRatio: 1.0,
|
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||||
},
|
inversionAttempts: 'dontInvert'
|
||||||
(decodedText) => {
|
});
|
||||||
if (decodedText.startsWith('SEEDPGP1:')) {
|
|
||||||
|
if (code) {
|
||||||
|
isCancelled = true;
|
||||||
|
stopScanning();
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
onScanSuccess(decodedText);
|
|
||||||
stopCamera();
|
// jsQR gives us raw bytes!
|
||||||
|
const rawBytes = code.binaryData;
|
||||||
|
|
||||||
|
// Detect binary (16 or 32 bytes with non-printable chars)
|
||||||
|
const isBinary = (rawBytes.length === 16 || rawBytes.length === 32) &&
|
||||||
|
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]/.test(String.fromCharCode(...Array.from(rawBytes)));
|
||||||
|
|
||||||
|
if (isBinary) {
|
||||||
|
onScanSuccess(new Uint8Array(rawBytes));
|
||||||
} else {
|
} else {
|
||||||
setError('QR code found, but not a valid SEEDPGP1 frame');
|
// Text QR - use the text property
|
||||||
|
onScanSuccess(code.data);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
() => {
|
setTimeout(() => onClose(), 1000);
|
||||||
// Ignore frequent scanning errors
|
}
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
// IMPORTANT: Check for null/undefined err object first.
|
||||||
|
if (!err) {
|
||||||
|
console.error('Caught a null or undefined error inside QRScanner.');
|
||||||
|
return; // Exit if error is falsy
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled || err.name === 'AbortError') {
|
||||||
|
console.log('Camera operation was cancelled or aborted, which is expected on unmount.');
|
||||||
|
return; // Ignore abort errors, they are expected on cleanup
|
||||||
|
}
|
||||||
|
|
||||||
console.error('Camera error:', err);
|
console.error('Camera error:', err);
|
||||||
setError(`Camera failed: ${err.message || 'Permission denied or not available'}`);
|
setHasPermission(false);
|
||||||
setScanning(false);
|
|
||||||
setScanMode(null);
|
let errorMsg = 'An unknown camera error occurred.';
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
errorMsg = 'Camera access was denied. Please grant permission in your browser settings.';
|
||||||
|
} else if (err.name === 'NotFoundError') {
|
||||||
|
errorMsg = 'No camera found on this device.';
|
||||||
|
} else if (err.name === 'NotReadableError') {
|
||||||
|
errorMsg = 'Cannot access the camera. It may be in use by another application or browser tab.';
|
||||||
|
} else if (err.name === 'OverconstrainedError') {
|
||||||
|
errorMsg = 'The camera does not meet the required constraints.';
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
errorMsg = `Camera error: ${err.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInternalError(errorMsg);
|
||||||
|
onError?.(errorMsg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
startScanning();
|
||||||
|
|
||||||
const stopCamera = async () => {
|
return () => {
|
||||||
if (html5QrCodeRef.current) {
|
isCancelled = true;
|
||||||
try {
|
stopScanning();
|
||||||
await html5QrCodeRef.current.stop();
|
|
||||||
html5QrCodeRef.current.clear();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error stopping camera:', err);
|
|
||||||
}
|
|
||||||
html5QrCodeRef.current = null;
|
|
||||||
}
|
|
||||||
setScanning(false);
|
|
||||||
setScanMode(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setError('');
|
|
||||||
setScanMode('file');
|
|
||||||
setScanning(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const html5QrCode = new Html5Qrcode('qr-reader-file');
|
|
||||||
|
|
||||||
// Try scanning with verbose mode
|
|
||||||
const decodedText = await html5QrCode.scanFile(file, true);
|
|
||||||
|
|
||||||
if (decodedText.startsWith('SEEDPGP1:')) {
|
|
||||||
setSuccess(true);
|
|
||||||
onScanSuccess(decodedText);
|
|
||||||
html5QrCode.clear();
|
|
||||||
} else {
|
|
||||||
setError(`Found QR code, but not SEEDPGP format: ${decodedText.substring(0, 30)}...`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('File scan error:', err);
|
|
||||||
|
|
||||||
// Provide helpful error messages
|
|
||||||
if (err.message?.includes('No MultiFormat')) {
|
|
||||||
setError('Could not detect QR code in image. Try: 1) Taking a clearer photo, 2) Ensuring good lighting, 3) Screenshot from the Backup tab');
|
|
||||||
} else {
|
|
||||||
setError(`Scan failed: ${err.message || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
// Reset file input so same file can be selected again
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = async () => {
|
|
||||||
await stopCamera();
|
|
||||||
onClose();
|
|
||||||
};
|
};
|
||||||
|
}, [onScanSuccess, onClose, onError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in zoom-in-95">
|
<div className="bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/50 p-6 max-w-md w-full mx-4 shadow-[0_0_40px_rgba(0,240,255,0.3)]">
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-4 text-white flex items-center justify-between">
|
<h3 className="text-lg font-semibold text-[#00f0ff] flex items-center gap-2" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Camera size={20} />
|
<Camera size={20} />
|
||||||
<h2 className="font-bold text-lg">Scan QR Code</h2>
|
Scan QR Code
|
||||||
</div>
|
</h3>
|
||||||
<button
|
<button onClick={onClose} className="p-2 hover:bg-[#1a1a2e] rounded-lg transition-colors border-2 border-[#00f0ff]/30">
|
||||||
onClick={handleClose}
|
<X size={20} className="text-[#6ef3f7]" />
|
||||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{internalError && (
|
||||||
<div className="p-6 space-y-4">
|
<div className="mb-4 p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 rounded-lg flex items-start gap-2 text-[#ff006e] text-sm">
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 border-l-4 border-red-500 rounded-r-lg flex gap-2 text-red-800 text-xs leading-relaxed">
|
|
||||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||||
<p>{error}</p>
|
<span>{internalError}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success Display */}
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="p-3 bg-green-50 border-l-4 border-green-500 rounded-r-lg flex gap-2 text-green-800 text-sm">
|
<div className="mb-4 p-3 bg-[#39ff14]/10 border-2 border-[#39ff14]/30 rounded-lg flex items-center gap-2 text-[#39ff14] text-sm">
|
||||||
<CheckCircle2 size={16} className="shrink-0 mt-0.5" />
|
<CheckCircle2 size={16} />
|
||||||
<p>QR code scanned successfully!</p>
|
<span>QR Code detected!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mode Selection */}
|
<div className="relative bg-black rounded-lg overflow-hidden border-2 border-[#00f0ff]/30">
|
||||||
{!scanMode && (
|
<video ref={videoRef} className="w-full h-64 object-cover" playsInline muted />
|
||||||
<div className="space-y-3">
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
<button
|
</div>
|
||||||
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"
|
{!hasPermission && !internalError && (
|
||||||
>
|
<p className="text-sm text-[#6ef3f7] mt-3 text-center">Requesting camera access...</p>
|
||||||
<Camera size={20} />
|
)}
|
||||||
Use Camera
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={onClose}
|
||||||
className="w-full py-4 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-slate-800 hover:to-slate-900 transition-all shadow-lg"
|
className="w-full mt-4 py-2 bg-[#1a1a2e] hover:bg-[#16213e] rounded-lg text-[#00f0ff] font-medium transition-all border-2 border-[#00f0ff]/50 hover:shadow-[0_0_15px_rgba(0,240,255,0.3)]"
|
||||||
>
|
>
|
||||||
<Upload size={20} />
|
Cancel
|
||||||
Upload Image
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
<div className="pt-4 border-t border-slate-200">
|
|
||||||
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
|
|
||||||
<Info size={14} className="shrink-0 mt-0.5 text-blue-600" />
|
|
||||||
<div>
|
|
||||||
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
|
|
||||||
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Camera View */}
|
|
||||||
{scanMode === 'camera' && scanning && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div id="qr-reader" className="rounded-lg overflow-hidden border-2 border-slate-200"></div>
|
|
||||||
<button
|
|
||||||
onClick={stopCamera}
|
|
||||||
className="w-full py-3 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
Stop Camera
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File Processing View */}
|
|
||||||
{scanMode === 'file' && scanning && (
|
|
||||||
<div className="py-8 text-center">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-blue-600"></div>
|
|
||||||
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hidden div for file scanning */}
|
|
||||||
<div id="qr-reader-file" className="hidden"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,16 +3,55 @@ import { Download } from 'lucide-react';
|
|||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
interface QrDisplayProps {
|
interface QrDisplayProps {
|
||||||
value: string;
|
value: string | Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||||
const [dataUrl, setDataUrl] = useState<string>('');
|
const [dataUrl, setDataUrl] = useState('');
|
||||||
|
const [debugInfo, setDebugInfo] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (!value) {
|
||||||
QRCode.toDataURL(value, {
|
setDataUrl('');
|
||||||
errorCorrectionLevel: 'M',
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateQR = async () => {
|
||||||
|
try {
|
||||||
|
console.log('🎨 QrDisplay generating QR for:', value);
|
||||||
|
console.log(' - Type:', value instanceof Uint8Array ? 'Uint8Array' : typeof value);
|
||||||
|
console.log(' - Length:', value.length);
|
||||||
|
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
console.log(' - Hex:', Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(''));
|
||||||
|
|
||||||
|
// Create canvas manually for precise control
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
// Use the toCanvas method with Uint8Array directly
|
||||||
|
await QRCode.toCanvas(canvas, [{
|
||||||
|
data: value,
|
||||||
|
mode: 'byte'
|
||||||
|
}], {
|
||||||
|
errorCorrectionLevel: 'L',
|
||||||
|
width: 512,
|
||||||
|
margin: 4,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = canvas.toDataURL('image/png');
|
||||||
|
setDataUrl(url);
|
||||||
|
setDebugInfo(`Binary QR: ${value.length} bytes`);
|
||||||
|
console.log('✅ Binary QR generated successfully');
|
||||||
|
} else {
|
||||||
|
// For string data
|
||||||
|
console.log(' - String data:', value.slice(0, 50));
|
||||||
|
|
||||||
|
const url = await QRCode.toDataURL(value, {
|
||||||
|
errorCorrectionLevel: 'L',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
width: 512,
|
width: 512,
|
||||||
margin: 4,
|
margin: 4,
|
||||||
@@ -20,22 +59,29 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
|||||||
dark: '#000000',
|
dark: '#000000',
|
||||||
light: '#FFFFFF'
|
light: '#FFFFFF'
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.then(setDataUrl)
|
|
||||||
.catch(console.error);
|
setDataUrl(url);
|
||||||
|
setDebugInfo(`String QR: ${value.length} chars`);
|
||||||
|
console.log('✅ String QR generated successfully');
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ QR generation error:', err);
|
||||||
|
setDebugInfo(`Error: ${err}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateQR();
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (!dataUrl) return;
|
if (!dataUrl) return;
|
||||||
|
|
||||||
// Generate filename: SeedPGP_YYYY-MM-DD_HHMMSS.png
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
const date = now.toISOString().split('T')[0];
|
||||||
const time = now.toTimeString().split(' ')[0].replace(/:/g, ''); // HHMMSS
|
const time = now.toTimeString().split(' ')[0].replace(/:/g, '');
|
||||||
const filename = `SeedPGP_${date}_${time}.png`;
|
const filename = `SeedPGP_${date}_${time}.png`;
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = dataUrl;
|
link.href = dataUrl;
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
@@ -47,25 +93,27 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
|||||||
if (!dataUrl) return null;
|
if (!dataUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="border-4 border-[#00f0ff] rounded-xl shadow-[0_0_40px_rgba(0,240,255,0.6)] p-4 bg-[#0a0a0f] space-y-4">
|
||||||
<div className="flex items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-200">
|
<div className="bg-[#16213e] p-6 rounded-lg inline-block shadow-[0_0_20px_rgba(0,240,255,0.3)] border-2 border-[#00f0ff]/30">
|
||||||
<img
|
<img src={dataUrl} alt="QR Code" className="w-full h-auto" />
|
||||||
src={dataUrl}
|
|
||||||
alt="SeedPGP QR Code"
|
|
||||||
className="w-80 h-80"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{debugInfo && (
|
||||||
|
<div className="text-xs text-[#6ef3f7] font-mono">
|
||||||
|
{debugInfo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition-all shadow-lg hover:shadow-xl"
|
className="flex items-center gap-2 px-4 py-2 bg-[#00f0ff] hover:bg-[#00f0ff]/80 text-[#0a0a0f] rounded-lg transition-all hover:shadow-[0_0_15px_rgba(0,240,255,0.5)]"
|
||||||
>
|
>
|
||||||
<Download size={18} />
|
<Download size={16} />
|
||||||
Download QR Code
|
Download QR Code
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 text-center max-w-sm">
|
<p className="text-xs text-[#6ef3f7]">
|
||||||
Downloads as: SeedPGP_2026-01-28_231645.png
|
Downloads as: SeedPGP_{new Date().toISOString().split('T')[0]}_HHMMSS.png
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
39
src/components/ReadOnly.tsx
Normal file
39
src/components/ReadOnly.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { WifiOff } from 'lucide-react';
|
||||||
|
|
||||||
|
type ReadOnlyProps = {
|
||||||
|
isReadOnly: boolean;
|
||||||
|
onToggle: (isReadOnly: boolean) => void;
|
||||||
|
buildHash: string;
|
||||||
|
appVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CSP_POLICY = `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none';`;
|
||||||
|
|
||||||
|
export function ReadOnly({ isReadOnly, onToggle, buildHash, appVersion }: ReadOnlyProps) {
|
||||||
|
return (
|
||||||
|
<div className="pt-3 border-t border-[#00f0ff]/30">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isReadOnly}
|
||||||
|
onChange={(e) => onToggle(e.target.checked)}
|
||||||
|
className="rounded text-[#00f0ff] focus:ring-2 focus:ring-[#00f0ff] transition-all"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-[#6ef3f7] group-hover:text-[#00f0ff] transition-colors">
|
||||||
|
Read-only Mode
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{isReadOnly && (
|
||||||
|
<div className="mt-4 p-3 bg-[#16213e] text-[#6ef3f7] rounded-lg text-xs space-y-2 animate-in fade-in border-2 border-[#00f0ff]/30">
|
||||||
|
<p className="font-bold flex items-center gap-2"><WifiOff size={14} /> Network & Persistence Disabled</p>
|
||||||
|
<div className="font-mono text-[10px] space-y-1">
|
||||||
|
<p><span className="font-semibold text-[#9d84b7]">Version:</span> {appVersion}</p>
|
||||||
|
<p><span className="font-semibold text-[#9d84b7]">Build:</span> {buildHash}</p>
|
||||||
|
<p className="pt-1 font-semibold text-[#9d84b7]">Content Security Policy:</p>
|
||||||
|
<p className="text-[#00f0ff] break-words">{CSP_POLICY}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/SecurityWarnings.tsx
Normal file
66
src/components/SecurityWarnings.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const SecurityWarnings: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Warning
|
||||||
|
icon="🧵"
|
||||||
|
title="JavaScript Strings are Immutable"
|
||||||
|
description="Strings cannot be overwritten in memory. Copies persist until garbage collection runs (timing unpredictable)."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🗑️"
|
||||||
|
title="No Guaranteed Memory Wiping"
|
||||||
|
description="JavaScript has no secure memory clearing. Sensitive data may linger in RAM until GC or browser restart."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="📋"
|
||||||
|
title="Clipboard Exposure"
|
||||||
|
description="Copied data is accessible to other tabs/apps. Browser extensions can read clipboard contents."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="💾"
|
||||||
|
title="Browser Storage Persistence"
|
||||||
|
description="localStorage survives browser restart. sessionStorage survives page refresh. Both readable by any script on this domain."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🔍"
|
||||||
|
title="DevTools Access"
|
||||||
|
description="All app state, memory, and storage visible in browser DevTools. Never use on untrusted devices."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🌐"
|
||||||
|
title="Network Risks (When Online)"
|
||||||
|
description="If hosted online: DNS, HTTPS, CDN, and browser can see usage patterns. Use offline/local for maximum security."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-[#00f0ff]/30 text-xs text-[#6ef3f7]">
|
||||||
|
<strong className="text-[#00f0ff]">Recommendation:</strong>{' '}
|
||||||
|
Use this tool on a dedicated offline device. Clear browser data after each use. Never use on shared/public computers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Warning = ({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className="text-lg flex-shrink-0">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-[#00f0ff] mb-1" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>{title}</div>
|
||||||
|
<div className="text-[#6ef3f7] leading-relaxed">{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
413
src/components/SeedBlender.tsx
Normal file
413
src/components/SeedBlender.tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* @file SeedBlender.tsx
|
||||||
|
* @summary Main component for the Seed Blending feature.
|
||||||
|
* @description This component provides a full UI for the multi-step seed blending process,
|
||||||
|
* handling various input formats, per-row decryption, and final output actions.
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock, Key, ArrowRight } from 'lucide-react';
|
||||||
|
import QRScanner from './QRScanner';
|
||||||
|
import { decryptFromSeed, detectEncryptionMode } from '../lib/seedpgp';
|
||||||
|
import { decryptFromKrux } from '../lib/krux';
|
||||||
|
import { decodeSeedQR } from '../lib/seedqr'; // New import
|
||||||
|
import { QrDisplay } from './QrDisplay';
|
||||||
|
import {
|
||||||
|
blendMnemonicsAsync,
|
||||||
|
checkXorStrength,
|
||||||
|
mnemonicToEntropy,
|
||||||
|
DiceStats,
|
||||||
|
calculateDiceStats,
|
||||||
|
detectBadPatterns,
|
||||||
|
diceToBytes,
|
||||||
|
hkdfExtractExpand,
|
||||||
|
entropyToMnemonic,
|
||||||
|
mixWithDiceAsync,
|
||||||
|
} from '../lib/seedblend';
|
||||||
|
|
||||||
|
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
return (...args: Parameters<F>): void => {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), waitFor);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MnemonicEntry {
|
||||||
|
id: number;
|
||||||
|
rawInput: string;
|
||||||
|
decryptedMnemonic: string | null;
|
||||||
|
isEncrypted: boolean;
|
||||||
|
inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr';
|
||||||
|
passwordRequired: boolean;
|
||||||
|
passwordInput: string;
|
||||||
|
error: string | null;
|
||||||
|
isValid: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
const createNewEntry = (): MnemonicEntry => ({
|
||||||
|
id: nextId++, rawInput: '', decryptedMnemonic: null, isEncrypted: false,
|
||||||
|
inputType: 'text', passwordRequired: false, passwordInput: '', error: null, isValid: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SeedBlenderProps {
|
||||||
|
onDirtyStateChange: (isDirty: boolean) => void;
|
||||||
|
setMnemonicForBackup: (mnemonic: string) => void;
|
||||||
|
requestTabChange: (tab: 'create' | 'backup' | 'restore' | 'seedblender') => void;
|
||||||
|
incomingSeed?: string; // NEW: seed from Create tab
|
||||||
|
onSeedReceived?: () => void; // NEW: callback after seed added
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestTabChange, incomingSeed, onSeedReceived }: SeedBlenderProps) {
|
||||||
|
const processedSeedsRef = useRef<Set<string>>(new Set());
|
||||||
|
const [entries, setEntries] = useState<MnemonicEntry[]>([createNewEntry()]);
|
||||||
|
const [showQRScanner, setShowQRScanner] = useState(false);
|
||||||
|
const [scanTargetIndex, setScanTargetIndex] = useState<number | null>(null);
|
||||||
|
const [blendedResult, setBlendedResult] = useState<{ blendedEntropy: Uint8Array; blendedMnemonic12: string; blendedMnemonic24?: string; } | null>(null);
|
||||||
|
const [xorStrength, setXorStrength] = useState<{ isWeak: boolean; uniqueBytes: number; } | null>(null);
|
||||||
|
const [blendError, setBlendError] = useState<string>('');
|
||||||
|
const [blending, setBlending] = useState(false);
|
||||||
|
const [diceRolls, setDiceRolls] = useState('');
|
||||||
|
const [diceStats, setDiceStats] = useState<DiceStats | null>(null);
|
||||||
|
const [dicePatternWarning, setDicePatternWarning] = useState<string | null>(null);
|
||||||
|
const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState<string | null>(null);
|
||||||
|
const [finalMnemonic, setFinalMnemonic] = useState<string | null>(null);
|
||||||
|
const [mixing, setMixing] = useState(false);
|
||||||
|
const [showFinalQR, setShowFinalQR] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isDirty = entries.some(e => e.rawInput.length > 0) || diceRolls.length > 0;
|
||||||
|
onDirtyStateChange(isDirty);
|
||||||
|
}, [entries, diceRolls, onDirtyStateChange]);
|
||||||
|
|
||||||
|
const addSeedEntry = (seed: string) => {
|
||||||
|
setEntries(currentEntries => {
|
||||||
|
const emptyEntryIndex = currentEntries.findIndex(e => !e.rawInput.trim());
|
||||||
|
if (emptyEntryIndex !== -1) {
|
||||||
|
return currentEntries.map((entry, index) =>
|
||||||
|
index === emptyEntryIndex
|
||||||
|
? { ...entry, rawInput: seed, decryptedMnemonic: seed, isValid: null, error: null }
|
||||||
|
: entry
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const newEntry = createNewEntry();
|
||||||
|
newEntry.rawInput = seed;
|
||||||
|
newEntry.decryptedMnemonic = seed;
|
||||||
|
return [...currentEntries, newEntry];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (incomingSeed && incomingSeed.trim()) {
|
||||||
|
// Check if we've already processed this exact seed
|
||||||
|
if (!processedSeedsRef.current.has(incomingSeed)) {
|
||||||
|
const isDuplicate = entries.some(e => e.decryptedMnemonic === incomingSeed);
|
||||||
|
if (!isDuplicate) {
|
||||||
|
addSeedEntry(incomingSeed);
|
||||||
|
processedSeedsRef.current.add(incomingSeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always notify parent to clear the incoming seed
|
||||||
|
onSeedReceived?.();
|
||||||
|
}
|
||||||
|
}, [incomingSeed]);
|
||||||
|
|
||||||
|
const handleLockAndClear = () => {
|
||||||
|
setEntries([createNewEntry()]);
|
||||||
|
setBlendedResult(null);
|
||||||
|
setXorStrength(null);
|
||||||
|
setBlendError('');
|
||||||
|
setDiceRolls('');
|
||||||
|
setDiceStats(null);
|
||||||
|
setDicePatternWarning(null);
|
||||||
|
setDiceOnlyMnemonic(null);
|
||||||
|
setFinalMnemonic(null);
|
||||||
|
setShowFinalQR(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const processEntries = async () => {
|
||||||
|
setBlending(true);
|
||||||
|
setBlendError('');
|
||||||
|
const validMnemonics = entries.map(e => e.decryptedMnemonic).filter((m): m is string => m !== null && m.length > 0);
|
||||||
|
|
||||||
|
const validityPromises = entries.map(async (entry) => {
|
||||||
|
if (!entry.rawInput.trim()) return { isValid: null, error: null };
|
||||||
|
if (entry.isEncrypted && !entry.decryptedMnemonic) return { isValid: null, error: null };
|
||||||
|
|
||||||
|
const textToValidate = entry.decryptedMnemonic || entry.rawInput;
|
||||||
|
try {
|
||||||
|
await mnemonicToEntropy(textToValidate.trim());
|
||||||
|
return { isValid: true, error: null };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { isValid: false, error: e.message || "Invalid mnemonic" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const newValidationResults = await Promise.all(validityPromises);
|
||||||
|
setEntries(currentEntries => currentEntries.map((e, i) => ({
|
||||||
|
...e,
|
||||||
|
isValid: newValidationResults[i]?.isValid ?? e.isValid,
|
||||||
|
error: newValidationResults[i]?.error ?? e.error,
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (validMnemonics.length > 0) {
|
||||||
|
try {
|
||||||
|
const result = await blendMnemonicsAsync(validMnemonics);
|
||||||
|
setBlendedResult(result);
|
||||||
|
setXorStrength(checkXorStrength(result.blendedEntropy));
|
||||||
|
} catch (e: any) { setBlendError(e.message); setBlendedResult(null); }
|
||||||
|
} else {
|
||||||
|
setBlendedResult(null);
|
||||||
|
}
|
||||||
|
setBlending(false);
|
||||||
|
};
|
||||||
|
debounce(processEntries, 300)();
|
||||||
|
}, [JSON.stringify(entries.map(e => e.decryptedMnemonic))]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const processDice = async () => {
|
||||||
|
setDiceStats(calculateDiceStats(diceRolls));
|
||||||
|
setDicePatternWarning(detectBadPatterns(diceRolls).message || null);
|
||||||
|
if (diceRolls.length >= 50) {
|
||||||
|
try {
|
||||||
|
const outputByteLength = (blendedResult && blendedResult.blendedEntropy.length >= 32) ? 32 : 16;
|
||||||
|
const diceOnlyEntropy = await hkdfExtractExpand(diceToBytes(diceRolls), outputByteLength, new TextEncoder().encode('dice-only'));
|
||||||
|
setDiceOnlyMnemonic(await entropyToMnemonic(diceOnlyEntropy));
|
||||||
|
} catch { setDiceOnlyMnemonic(null); }
|
||||||
|
} else { setDiceOnlyMnemonic(null); }
|
||||||
|
};
|
||||||
|
debounce(processDice, 200)();
|
||||||
|
}, [diceRolls, blendedResult]);
|
||||||
|
|
||||||
|
const updateEntry = (index: number, newProps: Partial<MnemonicEntry>) => {
|
||||||
|
setEntries(currentEntries => currentEntries.map((entry, i) => i === index ? { ...entry, ...newProps } : entry));
|
||||||
|
};
|
||||||
|
const handleAddEntry = () => setEntries([...entries, createNewEntry()]);
|
||||||
|
const handleRemoveEntry = (id: number) => {
|
||||||
|
if (entries.length > 1) setEntries(entries.filter(e => e.id !== id));
|
||||||
|
else setEntries([createNewEntry()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScan = (index: number) => {
|
||||||
|
setScanTargetIndex(index);
|
||||||
|
setShowQRScanner(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanSuccess = useCallback(async (scannedData: string | Uint8Array) => {
|
||||||
|
if (scanTargetIndex === null) return;
|
||||||
|
|
||||||
|
const scannedText = typeof scannedData === 'string'
|
||||||
|
? scannedData
|
||||||
|
: Array.from(scannedData).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
const mode = detectEncryptionMode(scannedText);
|
||||||
|
let mnemonic = scannedText;
|
||||||
|
let error: string | null = null;
|
||||||
|
let inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr' = 'text';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'seedqr') {
|
||||||
|
mnemonic = await decodeSeedQR(scannedText);
|
||||||
|
inputType = 'seedqr';
|
||||||
|
updateEntry(scanTargetIndex, {
|
||||||
|
rawInput: mnemonic,
|
||||||
|
decryptedMnemonic: mnemonic,
|
||||||
|
isEncrypted: false,
|
||||||
|
passwordRequired: false,
|
||||||
|
inputType,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else if (mode === 'pgp' || mode === 'krux') {
|
||||||
|
inputType = (mode === 'pgp' ? 'seedpgp' : mode);
|
||||||
|
updateEntry(scanTargetIndex, {
|
||||||
|
rawInput: scannedText,
|
||||||
|
decryptedMnemonic: null,
|
||||||
|
isEncrypted: true,
|
||||||
|
passwordRequired: true,
|
||||||
|
inputType,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else { // text or un-recognized
|
||||||
|
updateEntry(scanTargetIndex, {
|
||||||
|
rawInput: scannedText,
|
||||||
|
decryptedMnemonic: scannedText,
|
||||||
|
isEncrypted: false,
|
||||||
|
passwordRequired: false,
|
||||||
|
inputType: 'text',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message || "Failed to process QR code";
|
||||||
|
updateEntry(scanTargetIndex, { rawInput: scannedText, error });
|
||||||
|
}
|
||||||
|
setShowQRScanner(false);
|
||||||
|
}, [scanTargetIndex]);
|
||||||
|
|
||||||
|
const handleScanClose = useCallback(() => {
|
||||||
|
setShowQRScanner(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScanError = useCallback((errMsg: string) => {
|
||||||
|
if (scanTargetIndex !== null) {
|
||||||
|
updateEntry(scanTargetIndex, { error: errMsg });
|
||||||
|
}
|
||||||
|
}, [scanTargetIndex]);
|
||||||
|
|
||||||
|
const handleDecrypt = async (index: number) => {
|
||||||
|
const entry = entries[index];
|
||||||
|
if (!entry.isEncrypted || !entry.passwordInput) return;
|
||||||
|
try {
|
||||||
|
let mnemonic: string;
|
||||||
|
if (entry.inputType === 'krux') {
|
||||||
|
mnemonic = (await decryptFromKrux({ kefData: entry.rawInput, passphrase: entry.passwordInput })).mnemonic;
|
||||||
|
} else { // seedpgp
|
||||||
|
mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w;
|
||||||
|
}
|
||||||
|
updateEntry(index, { rawInput: mnemonic, decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
|
||||||
|
} catch (e: any) {
|
||||||
|
updateEntry(index, { error: e.message || "Decryption failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalMix = async () => {
|
||||||
|
if (!blendedResult) return;
|
||||||
|
setMixing(true);
|
||||||
|
try {
|
||||||
|
const outputBits = blendedResult.blendedEntropy.length >= 32 ? 256 : 128;
|
||||||
|
const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits);
|
||||||
|
setFinalMnemonic(result.finalMnemonic);
|
||||||
|
} catch (e) { setFinalMnemonic(null); } finally { setMixing(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransfer = () => {
|
||||||
|
if (!finalMnemonic) return;
|
||||||
|
// Set mnemonic for backup
|
||||||
|
setMnemonicForBackup(finalMnemonic);
|
||||||
|
// Switch to backup tab
|
||||||
|
requestTabChange('backup');
|
||||||
|
// DON'T auto-clear - user can use "Reset All" button if they want to start fresh
|
||||||
|
// This preserves the blended seed in case user wants to come back and export QR
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBorderColor = (isValid: boolean | null) => {
|
||||||
|
if (isValid === true) return 'border-[#39ff14] focus:ring-[#39ff14]';
|
||||||
|
if (isValid === false) return 'border-[#ff006e] focus:ring-[#ff006e]';
|
||||||
|
return 'border-[#00f0ff]/50 focus:ring-[#00f0ff]';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6 pb-20">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
||||||
|
Seed Blender
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 1: Input Mnemonics</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<div key={entry.id} className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/20">
|
||||||
|
{entry.passwordRequired ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between"><label className="text-sm font-semibold text-[#00f0ff]">Decrypt {entry.inputType.toUpperCase()} Mnemonic</label><button onClick={() => updateEntry(index, createNewEntry())} className="text-xs text-[#6ef3f7] hover:text-[#00f0ff]">× Cancel</button></div>
|
||||||
|
<p className="text-xs text-[#6ef3f7] truncate">Payload: <code className="text-[#9d84b7]">{entry.rawInput.substring(0, 40)}...</code></p>
|
||||||
|
<div className="flex gap-2"><input type="password" placeholder="Enter passphrase to decrypt..." value={entry.passwordInput} onChange={(e) => updateEntry(index, { passwordInput: e.target.value })} className="w-full p-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-sm font-mono text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)]" /><button onClick={() => handleDecrypt(index)} className="px-4 bg-[#ff006e] text-white rounded-lg font-semibold hover:bg-[#ff4d8f] hover:shadow-[0_0_15px_rgba(255,0,110,0.5)]"><Key size={16} /></button></div>
|
||||||
|
{entry.error && <p className="text-xs text-[#ff006e]">{entry.error}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Row 1: Textarea only */}
|
||||||
|
<textarea
|
||||||
|
value={entry.rawInput}
|
||||||
|
onChange={(e) => updateEntry(index, { rawInput: e.target.value, decryptedMnemonic: e.target.value, isValid: null, error: null })}
|
||||||
|
placeholder={`Mnemonic #${index + 1} (12 or 24 words)`}
|
||||||
|
className={`w-full h-24 p-3 bg-[#0a0a0f] border-2 rounded-lg font-mono text-xs placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all ${getBorderColor(entry.isValid)}`}
|
||||||
|
/>
|
||||||
|
{/* Row 2: QR button (left) and X button (right) */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => handleScan(index)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#16213e] border border-[#00f0ff] text-[#00f0ff] text-xs rounded-lg hover:bg-[#00f0ff20] transition-all"
|
||||||
|
title="Scan QR code"
|
||||||
|
>
|
||||||
|
<QrCode size={14} />
|
||||||
|
<span>Scan QR</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveEntry(entry.id)}
|
||||||
|
className="p-1.5 bg-[#16213e] border border-[#ff006e] text-[#ff006e] rounded-lg hover:bg-[#ff006e20] transition-all"
|
||||||
|
title="Remove mnemonic"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{entry.error && <p className="text-xs text-[#ff006e] px-1">{entry.error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={handleAddEntry} className="w-full py-2.5 bg-[#1a1a2e] hover:bg-[#16213e] text-[#00f0ff] rounded-lg font-semibold flex items-center justify-center gap-2 border-2 border-[#00f0ff]/50"><Plus size={16} /> Add Another Mnemonic</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 min-h-[10rem]">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 2: Blended Preview</h3>
|
||||||
|
{blending ? <p className="text-sm text-[#6ef3f7]">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><div><span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.</div></div>)}<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (12-word)</label><p data-sensitive="Blended Mnemonic (12-word)" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (24-word)</label><p data-sensitive="Blended Mnemonic (24-word)" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-[#6ef3f7]">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 3: Input Dice Rolls</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<textarea value={diceRolls} onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))} placeholder="99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-xs placeholder:text-[10px] placeholder:text-[#6ef3f7]" />
|
||||||
|
{dicePatternWarning && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><p><span className="font-bold">Warning:</span> {dicePatternWarning}</p></div>)}
|
||||||
|
{diceStats && diceStats.length > 0 && (<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center"><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Rolls</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.length}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Entropy (bits)</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.estimatedEntropyBits.toFixed(1)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Mean</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.mean.toFixed(2)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Chi-Square</p><p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-[#ff006e]' : 'text-[#00f0ff]'}`}>{diceStats.chiSquare.toFixed(2)}</p></div></div>)}
|
||||||
|
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Dice-Only Preview Mnemonic</label><p data-sensitive="Dice-Only Preview Mnemonic" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{diceOnlyMnemonic}</p></div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/50 shadow-[0_0_20px_rgba(0,240,255,0.3)]">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 4: Generate Final Mnemonic</h3>
|
||||||
|
{finalMnemonic ? (
|
||||||
|
<div className="p-4 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-2xl shadow-[0_0_20px_rgba(57,255,20,0.3)]">
|
||||||
|
<div className="flex items-center justify-between mb-4"><span className="font-bold text-[#39ff14] flex items-center gap-2 text-lg" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}><CheckCircle2 size={22} /> Final Mnemonic Generated</span><button onClick={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-[#16213e] rounded-xl transition-all text-[#39ff14] hover:shadow-[0_0_15px_rgba(57,255,20,0.5)] flex items-center gap-2"><EyeOff size={22} /> Hide</button></div>
|
||||||
|
<div className="p-6 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_20px_rgba(57,255,20,0.3)]"><p data-sensitive="Final Blended Mnemonic" className="font-mono text-center text-lg break-words text-[#39ff14]">{finalMnemonic}</p></div>
|
||||||
|
<div className="mt-4 p-3 bg-[#ff006e]/10 text-[#ff006e] rounded-lg text-xs flex gap-2 border-2 border-[#ff006e]/30"><AlertTriangle size={16} className="shrink-0 mt-0.5" /><span><strong>Security Warning:</strong> Write this down immediately. Do not save it digitally.</span></div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||||
|
<button onClick={() => setShowFinalQR(true)} className="w-full py-2.5 bg-[#1a1a2e] text-[#00f0ff] rounded-lg font-semibold flex items-center justify-center gap-2 border-2 border-[#00f0ff]/50 hover:bg-[#16213e] hover:shadow-[0_0_15px_rgba(0,240,255,0.3)]"><QrCode size={16} /> Export as QR</button>
|
||||||
|
<button
|
||||||
|
onClick={handleTransfer}
|
||||||
|
className="w-full py-2.5 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white rounded-lg font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-[#ff006e]"
|
||||||
|
style={{ textShadow: '0 0 10px rgba(255,255,255,0.8)' }}
|
||||||
|
disabled={!finalMnemonic}
|
||||||
|
>
|
||||||
|
<ArrowRight size={20} />
|
||||||
|
Send to Backup Tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<><p className="text-sm text-[#6ef3f7] mb-4">Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final mnemonic.</p><button onClick={handleFinalMix} disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing} className="w-full py-3 bg-gradient-to-r from-[#00f0ff] to-[#0066ff] text-[#16213e] rounded-xl font-bold flex items-center justify-center gap-2 disabled:opacity-50 hover:shadow-[0_0_20px_rgba(0,240,255,0.5)]">{mixing ? <RefreshCw className="animate-spin" size={20} /> : <Sparkles size={20} />}{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}</button></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showQRScanner && <QRScanner
|
||||||
|
onScanSuccess={handleScanSuccess}
|
||||||
|
onClose={handleScanClose}
|
||||||
|
onError={handleScanError}
|
||||||
|
/>}
|
||||||
|
{showFinalQR && finalMnemonic && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowFinalQR(false)}>
|
||||||
|
<div className="bg-[#16213e] rounded-2xl p-4 border-2 border-[#00f0ff]/50" onClick={e => e.stopPropagation()}>
|
||||||
|
<QrDisplay value={finalMnemonic} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/StorageDetails.tsx
Normal file
82
src/components/StorageDetails.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
src/components/badges/ClipboardBadge.tsx
Normal file
39
src/components/badges/ClipboardBadge.tsx
Normal 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-[#39ff14] bg-[#39ff14]/10 border-[#39ff14]/20" // Safe
|
||||||
|
: count < 5
|
||||||
|
? "text-[#ff006e] bg-[#ff006e]/10 border-[#ff006e]/30 font-semibold" // Warning
|
||||||
|
: "text-[#ff006e] bg-[#ff006e]/10 border-[#ff006e]/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;
|
||||||
28
src/components/badges/EditLockBadge.tsx
Normal file
28
src/components/badges/EditLockBadge.tsx
Normal 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-[#ff006e] bg-[#ff006e]/10 border-[#ff006e]/30 font-semibold'
|
||||||
|
: 'text-[#39ff14] bg-[#39ff14]/10 border-[#39ff14]/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;
|
||||||
20
src/components/badges/SecurityBadge.tsx
Normal file
20
src/components/badges/SecurityBadge.tsx
Normal 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;
|
||||||
34
src/components/badges/StorageBadge.tsx
Normal file
34
src/components/badges/StorageBadge.tsx
Normal 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;
|
||||||
@@ -2,6 +2,24 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Mobile-first: constrain to phone width on all devices */
|
||||||
|
#root {
|
||||||
|
max-width: 448px;
|
||||||
|
/* max-w-md = 28rem = 448px */
|
||||||
|
margin: 0 auto;
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: black;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure all content respects mobile width */
|
||||||
|
* {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
|||||||
105
src/lib/base43.test.ts
Normal file
105
src/lib/base43.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { base43Decode } from './base43';
|
||||||
|
|
||||||
|
// Helper to convert hex strings to Uint8Array
|
||||||
|
const toHex = (bytes: Uint8Array) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
describe('Base43 Decoding (Krux Official Test Vectors)', () => {
|
||||||
|
test('should decode empty string to empty Uint8Array', () => {
|
||||||
|
expect(base43Decode('')).toEqual(new Uint8Array(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error for forbidden characters', () => {
|
||||||
|
expect(() => base43Decode('INVALID!')).toThrow('forbidden character ! for base 43');
|
||||||
|
expect(() => base43Decode('INVALID_')).toThrow('forbidden character _ for base 43');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test cases adapted directly from Krux's test_baseconv.py
|
||||||
|
const kruxBase43TestVectors = [
|
||||||
|
{
|
||||||
|
hex: "61",
|
||||||
|
b43: "2B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "626262",
|
||||||
|
b43: "1+45$",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "636363",
|
||||||
|
b43: "1+-U-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "73696d706c792061206c6f6e6720737472696e67",
|
||||||
|
b43: "2YT--DWX-2WS5L5VEX1E:6E7C8VJ:E",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "00eb15231dfceb60925886b67d065299925915aeb172c06647",
|
||||||
|
b43: "03+1P14XU-QM.WJNJV$OBH4XOF5+E9OUY4E-2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "516b6fcd0f",
|
||||||
|
b43: "1CDVY/HG",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "bf4f89001e670274dd",
|
||||||
|
b43: "22DOOE00VVRUHY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "572e4794",
|
||||||
|
b43: "9.ZLRA",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "ecac89cad93923c02321",
|
||||||
|
b43: "F5JWS5AJ:FL5YV0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "10c8511e",
|
||||||
|
b43: "1-FFWO",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "00000000000000000000",
|
||||||
|
b43: "0000000000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "000111d38e5fc9071ffcd20b4a763cc9ae4f252bb4e48fd66a835e252ada93ff480d6dd43dc62a641155a5",
|
||||||
|
b43: "05V$PS0ZWYH7M1RH-$2L71TF23XQ*HQKJXQ96L5E9PPMWXXHT3G1IP.HT-540H",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hex: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
|
||||||
|
b43: "060PLMRVA3TFF18/LY/QMLZT76BH2EO*BDNG7S93KP5BBBLO2BW0YQXFWP8O$/XBSLCYPAIOZLD2O$:XX+XMI79BSZP-B7U8U*$/A3ML:P+RISP4I-NQ./-B4.DWOKMZKT4:5+M3GS/5L0GWXIW0ES5J-J$BX$FIWARF.L2S/J1V9SHLKBSUUOTZYLE7O8765J**C0U23SXMU$.-T9+0/8VMFU*+0KIF5:5W:/O:DPGOJ1DW2L-/LU4DEBBCRIFI*497XHHS0.-+P-2S98B/8MBY+NKI2UP-GVKWN2EJ4CWC3UX8K3AW:MR0RT07G7OTWJV$RG2DG41AGNIXWVYHUBHY8.+5/B35O*-Z1J3$H8DB5NMK6F2L5M/1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
kruxBase43TestVectors.forEach(({ hex, b43 }) => {
|
||||||
|
test(`should decode Base43 "${b43}" to hex "${hex}"`, () => {
|
||||||
|
const decodedBytes = base43Decode(b43);
|
||||||
|
expect(toHex(decodedBytes)).toEqual(hex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const specialKruxTestVectors = [
|
||||||
|
{
|
||||||
|
data: "1334+HGXM$F8PPOIRNHX0.R*:SBMHK$X88LX$*/Y417R/6S1ZQOB2LHM-L+4T1YQVU:B*CKGXONP7:Y/R-B*:$R8FK",
|
||||||
|
expectedErrorMessage: "Krux decryption failed - wrong passphrase or corrupted data" // This error is thrown by crypto.subtle.decrypt
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// We cannot fully test the user's specific case here without a corresponding Python encrypt function
|
||||||
|
// to get the expected decrypted bytes. However, we can at least confirm this decodes to *some* bytes.
|
||||||
|
specialKruxTestVectors.forEach(({ data }) => {
|
||||||
|
test(`should attempt to decode the user's special Base43 string "${data.substring(0,20)}..."`, () => {
|
||||||
|
const decodedBytes = base43Decode(data);
|
||||||
|
expect(decodedBytes).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(decodedBytes.length).toBeGreaterThan(0);
|
||||||
|
// Further validation would require the exact Python output (decrypted bytes)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should correctly decode the user-provided failing case', () => {
|
||||||
|
const b43 = "1334+HGXM$F8PPOIRNHX0.R*:SBMHK$X88LX$*/Y417R/6S1ZQOB2LHM-L+4T1YQVU:B*CKGXONP7:Y/R-B*:$R8FK";
|
||||||
|
const expectedHex = "0835646363373062641401a026315e057b79d6fa85280f20493fe0d310e8638ce9738dddcd458342cbc54a744b63057ee919ad05af041bb652561adc2e";
|
||||||
|
const decodedBytes = base43Decode(b43);
|
||||||
|
expect(toHex(decodedBytes)).toEqual(expectedHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
76
src/lib/base43.ts
Normal file
76
src/lib/base43.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* @file Base43 encoding/decoding, ported from Krux's pure_python_base_decode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const B43CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:";
|
||||||
|
const B43_MAP = new Map<string, bigint>();
|
||||||
|
for (let i = 0; i < B43CHARS.length; i++) {
|
||||||
|
B43_MAP.set(B43CHARS[i], BigInt(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a Base43 string into bytes.
|
||||||
|
* This is a direct port of the pure_python_base_decode function from Krux.
|
||||||
|
* @param v The Base43 encoded string.
|
||||||
|
* @returns The decoded bytes as a Uint8Array.
|
||||||
|
*/
|
||||||
|
export function base43Decode(str: string): Uint8Array {
|
||||||
|
// Handle empty string - should return empty array
|
||||||
|
if (str.length === 0) return new Uint8Array(0);
|
||||||
|
|
||||||
|
// Count leading '0' characters in input (these represent leading zero bytes)
|
||||||
|
const leadingZeroChars = str.match(/^0+/)?.[0].length || 0;
|
||||||
|
|
||||||
|
let value = 0n;
|
||||||
|
const base = 43n;
|
||||||
|
|
||||||
|
for (const char of str) {
|
||||||
|
const index = B43CHARS.indexOf(char);
|
||||||
|
if (index === -1) {
|
||||||
|
// Match Krux error message format
|
||||||
|
throw new Error(`forbidden character ${char} for base 43`);
|
||||||
|
}
|
||||||
|
value = value * base + BigInt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case: all zeros (e.g., "0000000000")
|
||||||
|
if (value === 0n) {
|
||||||
|
// Return array with length equal to number of '0' chars
|
||||||
|
return new Uint8Array(leadingZeroChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert BigInt to hex
|
||||||
|
let hex = value.toString(16);
|
||||||
|
if (hex.length % 2 !== 0) hex = '0' + hex;
|
||||||
|
|
||||||
|
// Calculate how many leading zero bytes we need
|
||||||
|
// Each Base43 '0' at the start represents one zero byte
|
||||||
|
// But we need to account for Base43 encoding: each char ~= log(43)/log(256) bytes
|
||||||
|
let leadingZeroBytes = leadingZeroChars;
|
||||||
|
|
||||||
|
// Pad hex with leading zeros
|
||||||
|
if (leadingZeroBytes > 0) {
|
||||||
|
hex = '00'.repeat(leadingZeroBytes) + hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base43Encode(data: Uint8Array): string {
|
||||||
|
let num = 0n;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
num = num * 256n + BigInt(data[i]);
|
||||||
|
}
|
||||||
|
let encoded = '';
|
||||||
|
if (num === 0n) return '0';
|
||||||
|
while (num > 0n) {
|
||||||
|
const remainder = Number(num % 43n);
|
||||||
|
encoded = B43CHARS[remainder] + encoded;
|
||||||
|
num = num / 43n;
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
13
src/lib/bip32.ts
Normal file
13
src/lib/bip32.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import * as bip39 from 'bip39';
|
||||||
|
import { BIP32Factory } from 'bip32';
|
||||||
|
import * as ecc from 'tiny-secp256k1';
|
||||||
|
|
||||||
|
const bip32 = BIP32Factory(ecc);
|
||||||
|
|
||||||
|
export function getWalletFingerprint(mnemonic: string): string {
|
||||||
|
const seed = bip39.mnemonicToSeedSync(mnemonic);
|
||||||
|
const root = bip32.fromSeed(Buffer.from(seed));
|
||||||
|
const fingerprint = root.fingerprint;
|
||||||
|
return Array.from(fingerprint).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
193
src/lib/krux.test.ts
Normal file
193
src/lib/krux.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// Krux KEF tests using Bun test runner
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
encryptToKrux,
|
||||||
|
decryptFromKrux,
|
||||||
|
hexToBytes,
|
||||||
|
bytesToHex,
|
||||||
|
wrap,
|
||||||
|
unwrap,
|
||||||
|
KruxCipher
|
||||||
|
} from './krux';
|
||||||
|
import { getWalletFingerprint } from "./bip32";
|
||||||
|
|
||||||
|
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 encrypted = await encryptToKrux({
|
||||||
|
mnemonic,
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedLabel = getWalletFingerprint(mnemonic);
|
||||||
|
|
||||||
|
expect(encrypted.kefBase43).toMatch(/^[0-9A-Z$*+-\./:]+$/); // Check Base43 format
|
||||||
|
expect(encrypted.label).toBe(expectedLabel);
|
||||||
|
expect(encrypted.iterations).toBe(100000);
|
||||||
|
expect(encrypted.version).toBe(20);
|
||||||
|
|
||||||
|
const decrypted = await decryptFromKrux({
|
||||||
|
kefData: encrypted.kefBase43, // Use kefBase43 for decryption
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decrypted.mnemonic).toBe(mnemonic);
|
||||||
|
expect(decrypted.label).toBe(expectedLabel);
|
||||||
|
expect(decrypted.iterations).toBe(100000);
|
||||||
|
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({
|
||||||
|
kefData: '123456',
|
||||||
|
passphrase: '',
|
||||||
|
})).rejects.toThrow('Invalid Krux data: Not a valid Hex or Base43 string.'); // Updated error message
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong passphrase fails decryption', async () => {
|
||||||
|
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
const correctPassphrase = 'correct';
|
||||||
|
const wrongPassphrase = 'wrong';
|
||||||
|
|
||||||
|
const { kefBase43 } = await encryptToKrux({ mnemonic, passphrase: correctPassphrase });
|
||||||
|
|
||||||
|
await expect(decryptFromKrux({
|
||||||
|
kefData: kefBase43,
|
||||||
|
passphrase: wrongPassphrase,
|
||||||
|
})).rejects.toThrow('Krux decryption failed - wrong passphrase or corrupted data');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test KruxCipher class directly
|
||||||
|
test('KruxCipher encrypt/decrypt roundtrip', async () => {
|
||||||
|
const cipher = new KruxCipher('passphrase', new TextEncoder().encode('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', new TextEncoder().encode('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'); // Changed error message
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KruxCipher rejects short payload', async () => {
|
||||||
|
const cipher = new KruxCipher('passphrase', new TextEncoder().encode('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', () => {
|
||||||
|
const label = 'Test';
|
||||||
|
const version = 20;
|
||||||
|
const payload = new TextEncoder().encode('test payload');
|
||||||
|
|
||||||
|
const wrapped1 = wrap(label, version, 200000, payload);
|
||||||
|
expect(wrapped1[6]).toBe(0);
|
||||||
|
expect(wrapped1[7]).toBe(0);
|
||||||
|
expect(wrapped1[8]).toBe(20);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// New test case for user-provided KEF string - this one already uses base43Decode
|
||||||
|
test('should correctly decrypt the user-provided KEF string', async () => {
|
||||||
|
const kefData = "1334+HGXM$F8PPOIRNHX0.R*:SBMHK$X88LX$*/Y417R/6S1ZQOB2LHM-L+4T1YQVU:B*CKGXONP7:Y/R-B*:$R8FK";
|
||||||
|
const passphrase = "aaa";
|
||||||
|
const expectedMnemonic = "differ release beauty fresh tortoise usage curtain spoil october town embrace ridge rough reject cabin snap glimpse enter book coach green lonely hundred mercy";
|
||||||
|
|
||||||
|
const result = await decryptFromKrux({ kefData, passphrase });
|
||||||
|
expect(result.mnemonic).toBe(expectedMnemonic);
|
||||||
|
});
|
||||||
|
});
|
||||||
248
src/lib/krux.ts
Normal file
248
src/lib/krux.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
// src/lib/krux.ts
|
||||||
|
// Krux KEF (Krux Encryption Format) implementation
|
||||||
|
import * as pako from 'pako';
|
||||||
|
import { base43Decode, base43Encode } from './base43';
|
||||||
|
import { getWalletFingerprint } from './bip32';
|
||||||
|
export const VERSIONS: Record<number, {
|
||||||
|
name: string;
|
||||||
|
compress: boolean;
|
||||||
|
auth: number;
|
||||||
|
}> = {
|
||||||
|
// We only implement the GCM versions as they are the only ones compatible with WebCrypto
|
||||||
|
20: { name: "AES-GCM", compress: false, auth: 4 },
|
||||||
|
21: { name: "AES-GCM +c", compress: true, auth: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const GCM_IV_LENGTH = 12;
|
||||||
|
|
||||||
|
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||||
|
// Create a new ArrayBuffer and copy the contents
|
||||||
|
const buffer = new ArrayBuffer(data.byteLength);
|
||||||
|
new Uint8Array(buffer).set(data);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrap(envelope: Uint8Array): { label: string; labelBytes: Uint8Array, 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];
|
||||||
|
if (!VERSIONS[version]) {
|
||||||
|
throw new Error(`Unsupported KEF version: ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, labelBytes, version, iterations, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
import { pbkdf2HmacSha256 } from './pbkdf2';
|
||||||
|
import { entropyToMnemonic, mnemonicToEntropy } from './seedblend';
|
||||||
|
|
||||||
|
// ... (rest of the file is the same until KruxCipher)
|
||||||
|
|
||||||
|
export class KruxCipher {
|
||||||
|
private keyPromise: Promise<CryptoKey>;
|
||||||
|
|
||||||
|
constructor(passphrase: string, salt: Uint8Array, iterations: number) {
|
||||||
|
this.keyPromise = (async () => {
|
||||||
|
// Use pure-JS PBKDF2 implementation which has been validated against Krux's test vector
|
||||||
|
const derivedKeyBytes = await pbkdf2HmacSha256(passphrase, salt, iterations, 32);
|
||||||
|
|
||||||
|
// Import the derived bytes as an AES-GCM key
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
toArrayBuffer(derivedKeyBytes),
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt function is unused in SeedBlender, but kept for completeness
|
||||||
|
async encrypt(plaintext: Uint8Array, version = 20, iv?: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const v = VERSIONS[version];
|
||||||
|
if (!v) throw new Error(`Unsupported KEF version: ${version}`);
|
||||||
|
|
||||||
|
let dataToEncrypt = plaintext;
|
||||||
|
if (v.compress) {
|
||||||
|
dataToEncrypt = pako.deflate(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ivBytes = iv ? new Uint8Array(iv) : crypto.getRandomValues(new Uint8Array(GCM_IV_LENGTH));
|
||||||
|
|
||||||
|
const key = await this.keyPromise;
|
||||||
|
const plaintextBuffer = toArrayBuffer(dataToEncrypt);
|
||||||
|
const ivBuffer = toArrayBuffer(ivBytes);
|
||||||
|
const tagLengthBits = v.auth * 8;
|
||||||
|
|
||||||
|
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv: ivBuffer, tagLength: tagLengthBits }, key, plaintextBuffer);
|
||||||
|
const encryptedBytes = new Uint8Array(encrypted);
|
||||||
|
const ciphertext = encryptedBytes.slice(0, encryptedBytes.length - v.auth);
|
||||||
|
const tag = encryptedBytes.slice(encryptedBytes.length - v.auth);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(payload: Uint8Array, version: number): Promise<Uint8Array> {
|
||||||
|
const v = VERSIONS[version];
|
||||||
|
if (!v) throw new Error(`Unsupported KEF version: ${version}`);
|
||||||
|
if (payload.length < GCM_IV_LENGTH + v.auth) throw new Error("Payload too short for AES-GCM");
|
||||||
|
|
||||||
|
const iv = payload.slice(0, GCM_IV_LENGTH);
|
||||||
|
const ciphertext = payload.slice(GCM_IV_LENGTH, payload.length - v.auth);
|
||||||
|
const tag = payload.slice(payload.length - v.auth);
|
||||||
|
|
||||||
|
const key = await this.keyPromise;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
|
||||||
|
ciphertextWithTag.set(ciphertext, 0);
|
||||||
|
ciphertextWithTag.set(tag, ciphertext.length);
|
||||||
|
|
||||||
|
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: toArrayBuffer(iv), tagLength: v.auth * 8 }, key, toArrayBuffer(ciphertextWithTag)
|
||||||
|
);
|
||||||
|
|
||||||
|
let decrypted = new Uint8Array(decryptedBuffer);
|
||||||
|
|
||||||
|
if (v.compress) {
|
||||||
|
decrypted = pako.inflate(decrypted);
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Krux decryption internal error:", error);
|
||||||
|
throw new Error("Krux decryption failed - wrong passphrase or corrupted data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptFromKrux(params: { kefData: string; passphrase: string; }): Promise<{ mnemonic: string; label: string; version: number; iterations: number }> {
|
||||||
|
const { kefData, passphrase } = params;
|
||||||
|
|
||||||
|
// STEP 1: Validate and decode data format (FIRST!)
|
||||||
|
let bytes: Uint8Array;
|
||||||
|
try {
|
||||||
|
bytes = hexToBytes(kefData);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
bytes = base43Decode(kefData);
|
||||||
|
} catch (e2) {
|
||||||
|
throw new Error("Invalid Krux data: Not a valid Hex or Base43 string.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 2: Unwrap and validate envelope structure
|
||||||
|
let label: string, labelBytes: Uint8Array, version: number, iterations: number, payload: Uint8Array;
|
||||||
|
try {
|
||||||
|
const unwrapped = unwrap(bytes);
|
||||||
|
label = unwrapped.label;
|
||||||
|
labelBytes = unwrapped.labelBytes;
|
||||||
|
version = unwrapped.version;
|
||||||
|
iterations = unwrapped.iterations;
|
||||||
|
payload = unwrapped.payload;
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error("Invalid Krux data: Not a valid Hex or Base43 string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 3: Check passphrase (only after data structure is validated)
|
||||||
|
if (!passphrase) {
|
||||||
|
throw new Error("Passphrase is required for Krux decryption");
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 4: Decrypt
|
||||||
|
const cipher = new KruxCipher(passphrase, labelBytes, iterations);
|
||||||
|
const decrypted = await cipher.decrypt(payload, version);
|
||||||
|
const mnemonic = await entropyToMnemonic(decrypted);
|
||||||
|
|
||||||
|
return { mnemonic, label, version, iterations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptToKrux(params: {
|
||||||
|
mnemonic: string;
|
||||||
|
passphrase: string;
|
||||||
|
}): Promise<{ kefBase43: string; label: string; version: number; iterations: number }> {
|
||||||
|
const { mnemonic, passphrase } = params;
|
||||||
|
|
||||||
|
if (!passphrase) throw new Error("Passphrase is required");
|
||||||
|
|
||||||
|
const label = getWalletFingerprint(mnemonic);
|
||||||
|
const iterations = 100000;
|
||||||
|
const version = 20;
|
||||||
|
|
||||||
|
const mnemonicBytes = await mnemonicToEntropy(mnemonic);
|
||||||
|
const cipher = new KruxCipher(passphrase, new TextEncoder().encode(label), iterations);
|
||||||
|
const payload = await cipher.encrypt(mnemonicBytes, version);
|
||||||
|
const kef = wrap(label, version, iterations, payload);
|
||||||
|
const kefBase43 = base43Encode(kef);
|
||||||
|
|
||||||
|
console.log('🔐 KEF Debug:', { label, iterations, version, length: kef.length, base43: kefBase43.slice(0, 50) });
|
||||||
|
|
||||||
|
return { kefBase43, label, version, iterations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrap(label: string, version: number, iterations: number, payload: Uint8Array): Uint8Array {
|
||||||
|
const labelBytes = new TextEncoder().encode(label);
|
||||||
|
const idLen = labelBytes.length;
|
||||||
|
|
||||||
|
// ADD THIS:
|
||||||
|
if (idLen > 252) {
|
||||||
|
throw new Error('Label too long');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert iterations to 3 bytes (Big-Endian)
|
||||||
|
// Scale down if > 10000 (Krux format: stores scaled value)
|
||||||
|
let scaledIter: number;
|
||||||
|
if (iterations >= 10000 && iterations % 10000 === 0) {
|
||||||
|
// Divisible by 10000 - store scaled
|
||||||
|
scaledIter = Math.floor(iterations / 10000);
|
||||||
|
} else {
|
||||||
|
// Store as-is (handles edge cases like 10001)
|
||||||
|
scaledIter = iterations;
|
||||||
|
}
|
||||||
|
const iterBytes = new Uint8Array(3);
|
||||||
|
iterBytes[0] = (scaledIter >> 16) & 0xFF;
|
||||||
|
iterBytes[1] = (scaledIter >> 8) & 0xFF;
|
||||||
|
iterBytes[2] = scaledIter & 0xFF;
|
||||||
|
|
||||||
|
// Calculate total length
|
||||||
|
const totalLength = 1 + idLen + 1 + 3 + payload.length;
|
||||||
|
const envelope = new Uint8Array(totalLength);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
envelope[offset++] = idLen;
|
||||||
|
envelope.set(labelBytes, offset);
|
||||||
|
offset += idLen;
|
||||||
|
envelope[offset++] = version;
|
||||||
|
envelope.set(iterBytes, offset);
|
||||||
|
offset += 3;
|
||||||
|
envelope.set(payload, offset);
|
||||||
|
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
87
src/lib/pbkdf2.ts
Normal file
87
src/lib/pbkdf2.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* @file pbkdf2.ts
|
||||||
|
* @summary A pure-JS implementation of PBKDF2-HMAC-SHA256 using the Web Crypto API.
|
||||||
|
* This is used as a fallback to test for platform inconsistencies in native PBKDF2.
|
||||||
|
* Adapted from public domain examples and RFC 2898.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs HMAC-SHA256 on a given key and data.
|
||||||
|
* @param key The HMAC key.
|
||||||
|
* @param data The data to hash.
|
||||||
|
* @returns A promise that resolves to the HMAC-SHA256 digest as an ArrayBuffer.
|
||||||
|
*/
|
||||||
|
async function hmacSha256(key: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
|
||||||
|
return crypto.subtle.sign('HMAC', key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The F function for PBKDF2 (PRF).
|
||||||
|
* T_1 = F(P, S, c, 1)
|
||||||
|
* T_2 = F(P, S, c, 2)
|
||||||
|
* ...
|
||||||
|
* F(P, S, c, i) = U_1 \xor U_2 \xor ... \xor U_c
|
||||||
|
* U_1 = PRF(P, S || INT_32_BE(i))
|
||||||
|
* U_2 = PRF(P, U_1)
|
||||||
|
* ...
|
||||||
|
* U_c = PRF(P, U_{c-1})
|
||||||
|
*/
|
||||||
|
async function F(passwordKey: CryptoKey, salt: Uint8Array, iterations: number, i: number): Promise<Uint8Array> {
|
||||||
|
// S || INT_32_BE(i)
|
||||||
|
const saltI = new Uint8Array(salt.length + 4);
|
||||||
|
saltI.set(salt, 0);
|
||||||
|
const i_be = new DataView(saltI.buffer, salt.length, 4);
|
||||||
|
i_be.setUint32(0, i, false); // false for big-endian
|
||||||
|
|
||||||
|
// U_1
|
||||||
|
let U = new Uint8Array(await hmacSha256(passwordKey, saltI.buffer));
|
||||||
|
// T
|
||||||
|
let T = U.slice();
|
||||||
|
|
||||||
|
for (let c = 1; c < iterations; c++) {
|
||||||
|
// U_c = PRF(P, U_{c-1})
|
||||||
|
U = new Uint8Array(await hmacSha256(passwordKey, U.buffer));
|
||||||
|
// T = T \xor U_c
|
||||||
|
for (let j = 0; j < T.length; j++) {
|
||||||
|
T[j] ^= U[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a key using PBKDF2-HMAC-SHA256.
|
||||||
|
* @param password The password string.
|
||||||
|
* @param salt The salt bytes.
|
||||||
|
* @param iterations The number of iterations.
|
||||||
|
* @param keyLenBytes The desired key length in bytes.
|
||||||
|
* @returns A promise that resolves to the derived key as a Uint8Array.
|
||||||
|
*/
|
||||||
|
export async function pbkdf2HmacSha256(password: string, salt: Uint8Array, iterations: number, keyLenBytes: number): Promise<Uint8Array> {
|
||||||
|
const passwordBytes = new TextEncoder().encode(password);
|
||||||
|
const passwordKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
passwordBytes,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const hLen = 32; // SHA-256 output length in bytes
|
||||||
|
const l = Math.ceil(keyLenBytes / hLen);
|
||||||
|
const r = keyLenBytes - (l - 1) * hLen;
|
||||||
|
|
||||||
|
const blocks: Uint8Array[] = [];
|
||||||
|
for (let i = 1; i <= l; i++) {
|
||||||
|
blocks.push(await F(passwordKey, salt, iterations, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
const T = new Uint8Array(keyLenBytes);
|
||||||
|
for(let i = 0; i < l - 1; i++) {
|
||||||
|
T.set(blocks[i], i * hLen);
|
||||||
|
}
|
||||||
|
T.set(blocks[l-1].slice(0, r), (l-1) * hLen);
|
||||||
|
|
||||||
|
return T;
|
||||||
|
}
|
||||||
159
src/lib/seedblend.test.ts
Normal file
159
src/lib/seedblend.test.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* @file Unit tests for the seedblend library.
|
||||||
|
* @summary These tests are a direct port of the unit tests from the
|
||||||
|
* 'dice_mix_interactive.py' script. Their purpose is to verify that the
|
||||||
|
* TypeScript/Web Crypto implementation is 100% logic-compliant with the
|
||||||
|
* Python reference script, producing identical, deterministic outputs for
|
||||||
|
* the same inputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
xorBytes,
|
||||||
|
hkdfExtractExpand,
|
||||||
|
mnemonicToEntropy,
|
||||||
|
entropyToMnemonic,
|
||||||
|
blendMnemonicsAsync,
|
||||||
|
mixWithDiceAsync,
|
||||||
|
diceToBytes,
|
||||||
|
detectBadPatterns,
|
||||||
|
calculateDiceStats,
|
||||||
|
} from './seedblend';
|
||||||
|
|
||||||
|
// Helper to convert hex strings to Uint8Array
|
||||||
|
const fromHex = (hex: string): Uint8Array => {
|
||||||
|
const bytes = hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16));
|
||||||
|
const buffer = new ArrayBuffer(bytes.length);
|
||||||
|
new Uint8Array(buffer).set(bytes);
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SeedBlend Logic Compliance Tests (Ported from Python)', () => {
|
||||||
|
|
||||||
|
test('should ensure XOR blending is order-independent (commutative)', () => {
|
||||||
|
const ent1 = fromHex("a1".repeat(16));
|
||||||
|
const ent2 = fromHex("b2".repeat(16));
|
||||||
|
const ent3 = fromHex("c3".repeat(16));
|
||||||
|
|
||||||
|
const blended1 = xorBytes(xorBytes(ent1, ent2), ent3);
|
||||||
|
const blended2 = xorBytes(xorBytes(ent3, ent2), ent1);
|
||||||
|
|
||||||
|
expect(blended1).toEqual(blended2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle XOR of different length inputs correctly', () => {
|
||||||
|
const ent128 = fromHex("a1".repeat(16)); // 12-word seed
|
||||||
|
const ent256 = fromHex("b2".repeat(32)); // 24-word seed
|
||||||
|
|
||||||
|
const blended = xorBytes(ent128, ent256);
|
||||||
|
expect(blended.length).toBe(32);
|
||||||
|
|
||||||
|
// Verify cycling: first 16 bytes should be a1^b2, last 16 should also be a1^b2
|
||||||
|
|
||||||
|
expect(blended.slice(0, 16) as Uint8Array).toEqual(xorBytes(fromHex("a1".repeat(16)), fromHex("b2".repeat(16))) as Uint8Array);
|
||||||
|
expect(blended.slice(16, 32) as Uint8Array).toEqual(xorBytes(fromHex("a1".repeat(16)), fromHex("b2".repeat(16))) as Uint8Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should perform a basic round-trip and validation for mnemonics', async () => {
|
||||||
|
const valid12 = "army van defense carry jealous true garbage claim echo media make crunch";
|
||||||
|
const ent12 = await mnemonicToEntropy(valid12);
|
||||||
|
expect(ent12.length).toBe(16);
|
||||||
|
|
||||||
|
const mnBack = await entropyToMnemonic(ent12);
|
||||||
|
const entBack = await mnemonicToEntropy(mnBack);
|
||||||
|
expect(ent12).toEqual(entBack);
|
||||||
|
|
||||||
|
const valid24 = "zone huge rather sad stomach ostrich real decline laptop glimpse gasp reunion garbage rain reopen furnace catch hire feed charge cheese liquid earn exchange";
|
||||||
|
const ent24 = await mnemonicToEntropy(valid24);
|
||||||
|
expect(ent24.length).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be deterministic for the same HKDF inputs', async () => {
|
||||||
|
const data = new Uint8Array(64).fill(0x01);
|
||||||
|
const info1 = new TextEncoder().encode('test');
|
||||||
|
const info2 = new TextEncoder().encode('different');
|
||||||
|
|
||||||
|
const out1 = await hkdfExtractExpand(data, 32, info1);
|
||||||
|
const out2 = await hkdfExtractExpand(data, 32, info1);
|
||||||
|
const out3 = await hkdfExtractExpand(data, 32, info2);
|
||||||
|
|
||||||
|
expect(out1).toEqual(out2);
|
||||||
|
expect(out1).not.toEqual(out3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should produce correct HKDF lengths and match prefixes', async () => {
|
||||||
|
const data = fromHex('ab'.repeat(32));
|
||||||
|
const info = new TextEncoder().encode('len-test');
|
||||||
|
|
||||||
|
const out16 = await hkdfExtractExpand(data, 16, info);
|
||||||
|
const out32 = await hkdfExtractExpand(data, 32, info);
|
||||||
|
|
||||||
|
expect(out16.length).toBe(16);
|
||||||
|
expect(out32.length).toBe(32);
|
||||||
|
expect(out16).toEqual(out32.slice(0, 16));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should detect bad dice patterns', () => {
|
||||||
|
expect(detectBadPatterns("1111111111").bad).toBe(true);
|
||||||
|
expect(detectBadPatterns("123456123456").bad).toBe(true);
|
||||||
|
expect(detectBadPatterns("222333444555").bad).toBe(true);
|
||||||
|
expect(detectBadPatterns("314159265358979323846264338327950").bad).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should calculate dice stats correctly', () => {
|
||||||
|
const rolls = "123456".repeat(10); // 60 rolls, perfectly uniform
|
||||||
|
const stats = calculateDiceStats(rolls);
|
||||||
|
|
||||||
|
expect(stats.length).toBe(60);
|
||||||
|
expect(stats.distribution).toEqual({ 1: 10, 2: 10, 3: 10, 4: 10, 5: 10, 6: 10 });
|
||||||
|
expect(stats.mean).toBeCloseTo(3.5);
|
||||||
|
expect(stats.chiSquare).toBe(0); // Perfect uniformity
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should convert dice to bytes using integer math', () => {
|
||||||
|
const rolls = "123456".repeat(17); // 102 rolls
|
||||||
|
const bytes = diceToBytes(rolls);
|
||||||
|
|
||||||
|
// Based on python script: `(102 * 2584965 // 1000000 + 7) // 8` = 33 bytes
|
||||||
|
expect(bytes.length).toBe(33);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Crucial Integration Tests ---
|
||||||
|
|
||||||
|
test('[CRITICAL] must reproduce the exact blended mnemonic for 4 seeds', async () => {
|
||||||
|
const sessionMnemonics = [
|
||||||
|
// 2x 24-word seeds
|
||||||
|
"dog guitar hotel random owner gadget salute riot patrol work advice panic erode leader pass cross section laundry elder asset soul scale immune scatter",
|
||||||
|
"unable point minimum sun peanut habit ready high nothing cherry silver eagle pen fabric list collect impact loan casual lyrics pig train middle screen",
|
||||||
|
// 2x 12-word seeds
|
||||||
|
"ethics super fog off merge misery atom sail domain bullet rather lamp",
|
||||||
|
"life repeat play screen initial slow run stumble vanish raven civil exchange"
|
||||||
|
];
|
||||||
|
|
||||||
|
const expectedMnemonic = "gasp question busy coral shrug jacket sample return main issue finish truck cage task tiny nerve desk treat feature balance idea timber dose crush";
|
||||||
|
|
||||||
|
const { blendedMnemonic24 } = await blendMnemonicsAsync(sessionMnemonics);
|
||||||
|
|
||||||
|
expect(blendedMnemonic24).toBe(expectedMnemonic);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[CRITICAL] must reproduce the exact final mixed output with 4 seeds and dice', async () => {
|
||||||
|
const sessionMnemonics = [
|
||||||
|
"dog guitar hotel random owner gadget salute riot patrol work advice panic erode leader pass cross section laundry elder asset soul scale immune scatter",
|
||||||
|
"unable point minimum sun peanut habit ready high nothing cherry silver eagle pen fabric list collect impact loan casual lyrics pig train middle screen",
|
||||||
|
"ethics super fog off merge misery atom sail domain bullet rather lamp",
|
||||||
|
"life repeat play screen initial slow run stumble vanish raven civil exchange"
|
||||||
|
];
|
||||||
|
const diceRolls = "3216534562134256361653421342634265362163523652413643616523462134652431625362543";
|
||||||
|
|
||||||
|
const expectedFinalMnemonic = "satisfy sphere banana negative blood divide force crime window fringe private market sense enjoy diet talent super abuse toss miss until visa inform dignity";
|
||||||
|
|
||||||
|
// Stage 1: Blend
|
||||||
|
const { blendedEntropy } = await blendMnemonicsAsync(sessionMnemonics);
|
||||||
|
|
||||||
|
// Stage 2: Mix
|
||||||
|
const { finalMnemonic } = await mixWithDiceAsync(blendedEntropy, diceRolls, 256);
|
||||||
|
|
||||||
|
expect(finalMnemonic).toBe(expectedFinalMnemonic);
|
||||||
|
});
|
||||||
|
});
|
||||||
475
src/lib/seedblend.ts
Normal file
475
src/lib/seedblend.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
/**
|
||||||
|
* @file Seed Blending Library for seedpgp-web
|
||||||
|
* @author Gemini
|
||||||
|
* @version 1.0.0
|
||||||
|
*
|
||||||
|
* @summary
|
||||||
|
* A direct and 100% logic-compliant port of the 'dice_mix_interactive.py'
|
||||||
|
* Python script to TypeScript for use in browser environments. This module
|
||||||
|
* implements XOR-based seed blending and HKDF-SHA256 enhancement with dice
|
||||||
|
* rolls using the Web Crypto API.
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* The process involves two stages:
|
||||||
|
* 1. **Mnemonic Blending**: Multiple BIP39 mnemonics are converted to their
|
||||||
|
* raw entropy and commutatively blended using a bitwise XOR operation.
|
||||||
|
* 2. **Dice Mixing**: The blended entropy is combined with entropy from a
|
||||||
|
* long string of physical dice rolls. The result is processed through
|
||||||
|
* HKDF-SHA256 to produce a final, cryptographically-strong mnemonic.
|
||||||
|
*
|
||||||
|
* This implementation strictly follows the Python script's logic, including
|
||||||
|
* checksum validation, bitwise operations, and cryptographic constructions,
|
||||||
|
* to ensure verifiable, deterministic outputs that match the reference script.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import wordlistTxt from '../bip39_wordlist.txt?raw';
|
||||||
|
|
||||||
|
// --- Isomorphic Crypto Setup ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously gets the appropriate SubtleCrypto interface, using a singleton
|
||||||
|
* pattern to ensure the module is loaded only once.
|
||||||
|
* This approach uses a dynamic import() to prevent Vite from bundling the
|
||||||
|
* Node.js 'crypto' module in browser builds.
|
||||||
|
*/
|
||||||
|
async function getCrypto(): Promise<SubtleCrypto> {
|
||||||
|
// Try browser Web Crypto API first
|
||||||
|
if (globalThis.crypto?.subtle) {
|
||||||
|
return globalThis.crypto.subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Node.js/Bun crypto module (for SSR and tests)
|
||||||
|
try {
|
||||||
|
const { webcrypto } = await import('crypto');
|
||||||
|
if (webcrypto?.subtle) {
|
||||||
|
return webcrypto.subtle as SubtleCrypto;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore import errors
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("SubtleCrypto not found in this environment");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||||
|
const buffer = new ArrayBuffer(data.byteLength);
|
||||||
|
new Uint8Array(buffer).set(data);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BIP39 Wordlist Loading ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BIP39 English wordlist, loaded directly from the project file.
|
||||||
|
*/
|
||||||
|
export const BIP39_WORDLIST: readonly string[] = wordlistTxt.trim().split('\n');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Map for fast, case-insensitive lookup of a word's index.
|
||||||
|
*/
|
||||||
|
export const WORD_INDEX = new Map<string, number>(
|
||||||
|
BIP39_WORDLIST.map((word, index) => [word, index])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (BIP39_WORDLIST.length !== 2048) {
|
||||||
|
throw new Error(`Invalid wordlist loaded: expected 2048 words, got ${BIP39_WORDLIST.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Web Crypto API Helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the SHA-256 hash of the given data.
|
||||||
|
* @param data The data to hash.
|
||||||
|
* @returns A promise that resolves to the hash as a Uint8Array.
|
||||||
|
*/
|
||||||
|
async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const subtle = await getCrypto();
|
||||||
|
const hashBuffer = await subtle.digest('SHA-256', toArrayBuffer(data));
|
||||||
|
return new Uint8Array(hashBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs an HMAC-SHA256 operation.
|
||||||
|
* @param key The HMAC key.
|
||||||
|
* @param data The data to authenticate.
|
||||||
|
* @returns A promise that resolves to the HMAC tag.
|
||||||
|
*/
|
||||||
|
async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const subtle = await getCrypto();
|
||||||
|
const cryptoKey = await subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
toArrayBuffer(key),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false, // not exportable
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
const signature = await subtle.sign('HMAC', cryptoKey, toArrayBuffer(data));
|
||||||
|
return new Uint8Array(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Core Cryptographic Functions (Ported from Python) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XOR two byte arrays, cycling the shorter one if lengths differ.
|
||||||
|
* This is a direct port of `xor_bytes` from the Python script.
|
||||||
|
*/
|
||||||
|
export function xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||||
|
const maxLen = Math.max(a.length, b.length);
|
||||||
|
const result = new Uint8Array(maxLen);
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
result[i] = a[i % a.length] ^ b[i % b.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An asynchronous, browser-compatible port of `hkdf_extract_expand` from the Python script.
|
||||||
|
* Implements HKDF using HMAC-SHA256 according to RFC 5869.
|
||||||
|
*
|
||||||
|
* @param keyMaterial The input keying material (IKM).
|
||||||
|
* @param length The desired output length in bytes.
|
||||||
|
* @param info Optional context and application specific information.
|
||||||
|
* @returns A promise resolving to the output keying material (OKM).
|
||||||
|
*/
|
||||||
|
export async function hkdfExtractExpand(
|
||||||
|
keyMaterial: Uint8Array,
|
||||||
|
length: number = 32,
|
||||||
|
info: Uint8Array = new Uint8Array(0)
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
// 1. Extract
|
||||||
|
const salt = new Uint8Array(32).fill(0); // Fixed zero salt, as in Python script
|
||||||
|
const prk = await hmacSha256(salt, keyMaterial);
|
||||||
|
|
||||||
|
// 2. Expand
|
||||||
|
let t = new Uint8Array(0);
|
||||||
|
let okm = new Uint8Array(length);
|
||||||
|
let written = 0;
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
while (written < length) {
|
||||||
|
const dataToHmac = new Uint8Array(t.length + info.length + 1);
|
||||||
|
dataToHmac.set(t, 0);
|
||||||
|
dataToHmac.set(info, t.length);
|
||||||
|
dataToHmac.set([counter], t.length + info.length);
|
||||||
|
|
||||||
|
t = new Uint8Array(await hmacSha256(prk, dataToHmac));
|
||||||
|
|
||||||
|
const toWrite = Math.min(t.length, length - written);
|
||||||
|
okm.set(t.slice(0, toWrite), written);
|
||||||
|
written += toWrite;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return okm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a BIP39 mnemonic string to its raw entropy bytes.
|
||||||
|
* Asynchronously performs checksum validation.
|
||||||
|
* This is a direct port of `mnemonic_to_bytes` from the Python script.
|
||||||
|
*/
|
||||||
|
export async function mnemonicToEntropy(mnemonicStr: string): Promise<Uint8Array> {
|
||||||
|
const words = mnemonicStr.trim().toLowerCase().split(/\s+/);
|
||||||
|
if (words.length !== 12 && words.length !== 24) {
|
||||||
|
throw new Error("Mnemonic must be 12 or 24 words");
|
||||||
|
}
|
||||||
|
|
||||||
|
let fullInt = 0n;
|
||||||
|
for (const word of words) {
|
||||||
|
const index = WORD_INDEX.get(word);
|
||||||
|
if (index === undefined) {
|
||||||
|
throw new Error(`Invalid word: ${word}`);
|
||||||
|
}
|
||||||
|
fullInt = (fullInt << 11n) | BigInt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBits = words.length * 11;
|
||||||
|
const CS = totalBits / 33; // 4 for 12 words, 8 for 24 words
|
||||||
|
const entropyBits = totalBits - CS;
|
||||||
|
|
||||||
|
let entropyInt = fullInt >> BigInt(CS);
|
||||||
|
const entropyBytes = new Uint8Array(entropyBits / 8);
|
||||||
|
|
||||||
|
for (let i = entropyBytes.length - 1; i >= 0; i--) {
|
||||||
|
entropyBytes[i] = Number(entropyInt & 0xFFn);
|
||||||
|
entropyInt >>= 8n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
const hashBytes = await sha256(entropyBytes);
|
||||||
|
const computedChecksum = hashBytes[0] >> (8 - CS);
|
||||||
|
const originalChecksum = Number(fullInt & ((1n << BigInt(CS)) - 1n));
|
||||||
|
|
||||||
|
if (originalChecksum !== computedChecksum) {
|
||||||
|
throw new Error("Invalid mnemonic checksum");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entropyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts raw entropy bytes to a BIP39 mnemonic string.
|
||||||
|
* Asynchronously calculates and appends the checksum.
|
||||||
|
* This is a direct port of `bytes_to_mnemonic` from the Python script.
|
||||||
|
*/
|
||||||
|
export async function entropyToMnemonic(entropyBytes: Uint8Array): Promise<string> {
|
||||||
|
const ENT = entropyBytes.length * 8;
|
||||||
|
if (ENT !== 128 && ENT !== 256) {
|
||||||
|
throw new Error("Entropy must be 128 or 256 bits");
|
||||||
|
}
|
||||||
|
const CS = ENT / 32;
|
||||||
|
|
||||||
|
const hashBytes = await sha256(entropyBytes);
|
||||||
|
const checksum = hashBytes[0] >> (8 - CS);
|
||||||
|
|
||||||
|
let entropyInt = 0n;
|
||||||
|
for (const byte of entropyBytes) {
|
||||||
|
entropyInt = (entropyInt << 8n) | BigInt(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullInt = (entropyInt << BigInt(CS)) | BigInt(checksum);
|
||||||
|
const totalBits = ENT + CS;
|
||||||
|
|
||||||
|
const mnemonicWords: string[] = [];
|
||||||
|
for (let i = 0; i < totalBits / 11; i++) {
|
||||||
|
const shift = BigInt(totalBits - (i + 1) * 11);
|
||||||
|
const index = Number((fullInt >> shift) & 0x7FFn);
|
||||||
|
mnemonicWords.push(BIP39_WORDLIST[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mnemonicWords.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Dice and Statistical Functions ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a string of dice rolls to a byte array using integer-based math
|
||||||
|
* to avoid floating point precision issues.
|
||||||
|
* This is a direct port of the dice conversion logic from the Python script.
|
||||||
|
*/
|
||||||
|
export function diceToBytes(diceRolls: string): Uint8Array {
|
||||||
|
const n = diceRolls.length;
|
||||||
|
|
||||||
|
// Integer-based calculation of bits: n * log2(6)
|
||||||
|
// log2(6) ≈ 2.5849625, so we use a scaled integer 2584965 for precision.
|
||||||
|
const totalBits = Math.floor(n * 2584965 / 1000000);
|
||||||
|
const diceBytesLen = Math.ceil(totalBits / 8);
|
||||||
|
|
||||||
|
let diceInt = 0n;
|
||||||
|
for (const roll of diceRolls) {
|
||||||
|
const value = parseInt(roll, 10);
|
||||||
|
if (isNaN(value) || value < 1 || value > 6) {
|
||||||
|
throw new Error(`Invalid dice roll: '${roll}'. Must be 1-6.`);
|
||||||
|
}
|
||||||
|
diceInt = diceInt * 6n + BigInt(value - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diceBytesLen === 0 && diceInt > 0n) {
|
||||||
|
// This case should not be hit with reasonable inputs but is a safeguard.
|
||||||
|
throw new Error("Cannot represent non-zero dice value in zero bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diceBytes = new Uint8Array(diceBytesLen);
|
||||||
|
for (let i = diceBytes.length - 1; i >= 0; i--) {
|
||||||
|
diceBytes[i] = Number(diceInt & 0xFFn);
|
||||||
|
diceInt >>= 8n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return diceBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects statistically unlikely patterns in a string of dice rolls.
|
||||||
|
* This is a direct port of `detect_bad_patterns`.
|
||||||
|
*/
|
||||||
|
export function detectBadPatterns(diceRolls: string): { bad: boolean; message?: string } {
|
||||||
|
const patterns = [
|
||||||
|
/1{5,}/, /2{5,}/, /3{5,}/, /4{5,}/, /5{5,}/, /6{5,}/, // Long repeats
|
||||||
|
/(123456){2,}/, /(654321){2,}/, /(123){3,}/, /(321){3,}/, // Sequences
|
||||||
|
/(?:222333444|333444555|444555666)/, // Grouped increments
|
||||||
|
/(\d)\1{4,}/, // Any digit repeated 5+
|
||||||
|
/(?:121212|131313|141414|151515|161616){2,}/, // Alternating
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.test(diceRolls)) {
|
||||||
|
return { bad: true, message: `Bad pattern detected: matches ${pattern.source}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bad: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for dice roll statistics.
|
||||||
|
*/
|
||||||
|
export interface DiceStats {
|
||||||
|
length: number;
|
||||||
|
distribution: Record<number, number>;
|
||||||
|
mean: number;
|
||||||
|
stdDev: number;
|
||||||
|
estimatedEntropyBits: number;
|
||||||
|
chiSquare: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates and returns various statistics for the given dice rolls.
|
||||||
|
* Ported from `calculate_dice_stats` and the main script's stats logic.
|
||||||
|
*/
|
||||||
|
export function calculateDiceStats(diceRolls: string): DiceStats {
|
||||||
|
if (!diceRolls) {
|
||||||
|
return { length: 0, distribution: {}, mean: 0, stdDev: 0, estimatedEntropyBits: 0, chiSquare: 0 };
|
||||||
|
}
|
||||||
|
const rolls = diceRolls.split('').map(c => parseInt(c, 10));
|
||||||
|
const n = rolls.length;
|
||||||
|
|
||||||
|
const counts: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
|
||||||
|
for (const roll of rolls) {
|
||||||
|
counts[roll]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = rolls.reduce((a, b) => a + b, 0);
|
||||||
|
const mean = sum / n;
|
||||||
|
|
||||||
|
|
||||||
|
const stdDev = n > 1 ? Math.sqrt(rolls.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n - 1)) : 0;
|
||||||
|
|
||||||
|
const estimatedEntropyBits = n * Math.log2(6);
|
||||||
|
|
||||||
|
const expected = n / 6;
|
||||||
|
let chiSquare = 0;
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
chiSquare += Math.pow(counts[i] - expected, 2) / expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
length: n,
|
||||||
|
distribution: counts,
|
||||||
|
mean: mean,
|
||||||
|
stdDev: stdDev,
|
||||||
|
estimatedEntropyBits,
|
||||||
|
chiSquare,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Main Blending Logic ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for weak XOR results (low diversity or all zeros).
|
||||||
|
* Ported from the main logic in the Python script.
|
||||||
|
*/
|
||||||
|
export function checkXorStrength(blendedEntropy: Uint8Array): {
|
||||||
|
isWeak: boolean;
|
||||||
|
uniqueBytes: number;
|
||||||
|
allZeros: boolean;
|
||||||
|
} {
|
||||||
|
const uniqueBytes = new Set(blendedEntropy).size;
|
||||||
|
const allZeros = blendedEntropy.every(byte => byte === 0);
|
||||||
|
|
||||||
|
// Heuristic from Python script: < 32 unique bytes is a warning.
|
||||||
|
return {
|
||||||
|
isWeak: uniqueBytes < 32 || allZeros,
|
||||||
|
uniqueBytes,
|
||||||
|
allZeros,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Main Blending & Mixing Orchestration ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 1: Asynchronously blends multiple mnemonics using XOR.
|
||||||
|
*
|
||||||
|
* @param mnemonics An array of mnemonic strings to blend.
|
||||||
|
* @returns A promise that resolves to the blended entropy and preview mnemonics.
|
||||||
|
*/
|
||||||
|
export async function blendMnemonicsAsync(mnemonics: string[]): Promise<{
|
||||||
|
blendedEntropy: Uint8Array;
|
||||||
|
blendedMnemonic12: string;
|
||||||
|
blendedMnemonic24?: string;
|
||||||
|
maxEntropyBits: number;
|
||||||
|
}> {
|
||||||
|
if (mnemonics.length === 0) {
|
||||||
|
throw new Error("At least one mnemonic is required for blending.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entropies = await Promise.all(mnemonics.map(mnemonicToEntropy));
|
||||||
|
|
||||||
|
let maxEntropyBits = 128;
|
||||||
|
for (const entropy of entropies) {
|
||||||
|
if (entropy.length * 8 > maxEntropyBits) {
|
||||||
|
maxEntropyBits = entropy.length * 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commutative XOR blending
|
||||||
|
let blendedEntropy = entropies[0];
|
||||||
|
for (let i = 1; i < entropies.length; i++) {
|
||||||
|
blendedEntropy = xorBytes(blendedEntropy, entropies[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate previews
|
||||||
|
const blendedMnemonic12 = await entropyToMnemonic(blendedEntropy.slice(0, 16));
|
||||||
|
let blendedMnemonic24: string | undefined;
|
||||||
|
if (blendedEntropy.length >= 32) {
|
||||||
|
blendedMnemonic24 = await entropyToMnemonic(blendedEntropy.slice(0, 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blendedEntropy,
|
||||||
|
blendedMnemonic12,
|
||||||
|
blendedMnemonic24,
|
||||||
|
maxEntropyBits
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 2: Asynchronously mixes blended entropy with dice rolls using HKDF.
|
||||||
|
*
|
||||||
|
* @param blendedEntropy The result from the XOR blending stage.
|
||||||
|
* @param diceRolls A string of dice rolls (e.g., "16345...").
|
||||||
|
* @param outputBits The desired final entropy size (128 or 256).
|
||||||
|
* @param info A domain separation tag for HKDF.
|
||||||
|
* @returns A promise that resolves to the final mnemonic and related data.
|
||||||
|
*/
|
||||||
|
export async function mixWithDiceAsync(
|
||||||
|
blendedEntropy: Uint8Array,
|
||||||
|
diceRolls: string,
|
||||||
|
outputBits: 128 | 256 = 256,
|
||||||
|
info: string = 'seedsigner-dice-mix'
|
||||||
|
): Promise<{
|
||||||
|
finalEntropy: Uint8Array;
|
||||||
|
finalMnemonic: string;
|
||||||
|
diceOnlyMnemonic: string;
|
||||||
|
}> {
|
||||||
|
if (diceRolls.length < 50) {
|
||||||
|
throw new Error("A minimum of 50 dice rolls is required (99+ recommended).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diceBytes = diceToBytes(diceRolls);
|
||||||
|
const outputByteLength = outputBits === 128 ? 16 : 32;
|
||||||
|
const infoBytes = new TextEncoder().encode(info);
|
||||||
|
const diceOnlyInfoBytes = new TextEncoder().encode('dice-only');
|
||||||
|
|
||||||
|
// Generate dice-only preview
|
||||||
|
const diceOnlyEntropy = await hkdfExtractExpand(diceBytes, outputByteLength, diceOnlyInfoBytes);
|
||||||
|
const diceOnlyMnemonic = await entropyToMnemonic(diceOnlyEntropy);
|
||||||
|
|
||||||
|
// Combine blended entropy with dice bytes
|
||||||
|
const combinedMaterial = new Uint8Array(blendedEntropy.length + diceBytes.length);
|
||||||
|
combinedMaterial.set(blendedEntropy, 0);
|
||||||
|
combinedMaterial.set(diceBytes, blendedEntropy.length);
|
||||||
|
|
||||||
|
// Apply HKDF to the combined material
|
||||||
|
const finalEntropy = await hkdfExtractExpand(combinedMaterial, outputByteLength, infoBytes);
|
||||||
|
const finalMnemonic = await entropyToMnemonic(finalEntropy);
|
||||||
|
|
||||||
|
return {
|
||||||
|
finalEntropy,
|
||||||
|
finalMnemonic,
|
||||||
|
diceOnlyMnemonic,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
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 } from "./krux";
|
||||||
|
import { decodeSeedQR } from './seedqr';
|
||||||
|
import type {
|
||||||
|
SeedPgpPlaintext,
|
||||||
|
ParsedSeedPgpFrame,
|
||||||
|
EncryptionMode,
|
||||||
|
EncryptionParams,
|
||||||
|
DecryptionParams,
|
||||||
|
EncryptionResult
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// Configure OpenPGP.js (disable warnings)
|
||||||
|
openpgp.config.showComment = false;
|
||||||
|
openpgp.config.showVersion = false;
|
||||||
|
openpgp.config.allowUnauthenticatedMessages = true; // Suppress AES warning
|
||||||
|
openpgp.config.allowUnauthenticatedStream = true; // Suppress stream warning
|
||||||
|
|
||||||
function nonEmptyTrimmed(s?: string): string | undefined {
|
function nonEmptyTrimmed(s?: string): string | undefined {
|
||||||
if (!s) return undefined;
|
if (!s) return undefined;
|
||||||
@@ -39,13 +54,11 @@ export function frameEncode(pgpBinary: Uint8Array): string {
|
|||||||
|
|
||||||
export function frameParse(text: string): ParsedSeedPgpFrame {
|
export function frameParse(text: string): ParsedSeedPgpFrame {
|
||||||
const s = text.trim().replace(/^["']|["']$/g, "").replace(/[\n\r\t]/g, "");
|
const s = text.trim().replace(/^["']|["']$/g, "").replace(/[\n\r\t]/g, "");
|
||||||
if (!s.startsWith("SEEDPGP1:")) throw new Error("Missing SEEDPGP1: prefix");
|
if (s.startsWith("SEEDPGP1:")) {
|
||||||
|
|
||||||
const parts = s.split(":");
|
const parts = s.split(":");
|
||||||
if (parts.length < 4) {
|
if (parts.length < 4) {
|
||||||
throw new Error("Invalid frame format (need at least 4 colon-separated parts)");
|
throw new Error("Invalid frame format (need at least 4 colon-separated parts)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = parts[0];
|
const prefix = parts[0];
|
||||||
const frame = parts[1];
|
const frame = parts[1];
|
||||||
const crc16 = parts[2].toUpperCase();
|
const crc16 = parts[2].toUpperCase();
|
||||||
@@ -56,11 +69,22 @@ export function frameParse(text: string): ParsedSeedPgpFrame {
|
|||||||
if (!/^[0-9A-F]{4}$/.test(crc16)) throw new Error("Invalid CRC16 format (must be 4 hex chars)");
|
if (!/^[0-9A-F]{4}$/.test(crc16)) throw new Error("Invalid CRC16 format (must be 4 hex chars)");
|
||||||
|
|
||||||
return { kind: "single", crc16, b45 };
|
return { kind: "single", crc16, b45 };
|
||||||
|
} else {
|
||||||
|
// It's not a full frame. Assume the ENTIRE string is the base45 payload.
|
||||||
|
// We will have to skip the CRC check.
|
||||||
|
return { kind: "single", crc16: "0000", b45: s, rawPayload: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function frameDecodeToPgpBytes(frameText: string): Uint8Array {
|
export function frameDecodeToPgpBytes(frameText: string): Uint8Array {
|
||||||
const f = frameParse(frameText);
|
const f = frameParse(frameText);
|
||||||
const pgp = base45Decode(f.b45);
|
const pgp = base45Decode(f.b45);
|
||||||
|
|
||||||
|
// If it's a raw payload, we cannot and do not verify the CRC.
|
||||||
|
if (f.rawPayload) {
|
||||||
|
return pgp;
|
||||||
|
}
|
||||||
|
|
||||||
const crc = crc16CcittFalse(pgp);
|
const crc = crc16CcittFalse(pgp);
|
||||||
if (crc !== f.crc16) {
|
if (crc !== f.crc16) {
|
||||||
throw new Error(`CRC16 mismatch! Expected: ${f.crc16}, Got: ${crc}. QR scan may be corrupted.`);
|
throw new Error(`CRC16 mismatch! Expected: ${f.crc16}, Got: ${crc}. QR scan may be corrupted.`);
|
||||||
@@ -188,3 +212,164 @@ 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: passphrase
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
framed: result.kefBase43,
|
||||||
|
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({
|
||||||
|
kefData: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'seedqr') {
|
||||||
|
try {
|
||||||
|
const mnemonic = await decodeSeedQR(params.frameText);
|
||||||
|
// Convert to SeedPgpPlaintext format for consistency
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
t: "bip39",
|
||||||
|
w: mnemonic,
|
||||||
|
l: "en",
|
||||||
|
pp: 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(`SeedQR decoding 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
|
||||||
|
*/
|
||||||
|
import { B43CHARS } from "./base43"; // Assuming B43CHARS is exported from base43.ts
|
||||||
|
|
||||||
|
// ... (other imports)
|
||||||
|
|
||||||
|
const BASE43_CHARS_ONLY_REGEX = new RegExp(`^[${B43CHARS.replace(/[\-\]\\]/g, '\\$&')}]+$`);
|
||||||
|
|
||||||
|
export function detectEncryptionMode(text: string): EncryptionMode {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
// 1. Definite PGP
|
||||||
|
if (trimmed.startsWith('SEEDPGP1:')) {
|
||||||
|
return 'pgp';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Tentative SeedQR detection
|
||||||
|
// Standard SeedQR is all digits, often long. (e.g., 00010002...)
|
||||||
|
if (/^\\d+$/.test(trimmed) && trimmed.length >= 12 * 4) { // Minimum 12 words * 4 digits
|
||||||
|
return 'seedqr';
|
||||||
|
}
|
||||||
|
// Compact SeedQR is all hex, often long. (e.g., 0e54b641...)
|
||||||
|
if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length >= 16 * 2) { // Minimum 16 bytes * 2 hex chars (for 12 words)
|
||||||
|
return 'seedqr';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Tentative Krux detection
|
||||||
|
const cleanedHex = trimmed.replace(/\s/g, '').replace(/^KEF:/i, '');
|
||||||
|
if (/^[0-9a-fA-F]{10,}$/.test(cleanedHex)) { // Krux hex format (min 5 bytes, usually longer)
|
||||||
|
return 'krux';
|
||||||
|
}
|
||||||
|
if (BASE43_CHARS_ONLY_REGEX.test(trimmed)) { // Krux Base43 format (e.g., 1334+HGXM$F8...)
|
||||||
|
return 'krux';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Likely a plain text mnemonic (contains spaces)
|
||||||
|
if (trimmed.includes(' ')) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Default to text
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|||||||
36
src/lib/seedqr.test.ts
Normal file
36
src/lib/seedqr.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// seedqr.test.ts
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { encodeStandardSeedQR, encodeCompactSeedQREntropy } from './seedqr';
|
||||||
|
|
||||||
|
describe('SeedQR encoding (SeedSigner test vectors)', () => {
|
||||||
|
it('encodes 24-word seed to correct Standard SeedQR digit stream (Test Vector 3)', async () => {
|
||||||
|
const mnemonic =
|
||||||
|
'sound federal bonus bleak light raise false engage round stock update render quote truck quality fringe palace foot recipe labor glow tortoise potato still';
|
||||||
|
|
||||||
|
const expectedDigitStream =
|
||||||
|
'166206750203018810361417065805941507171219081456140818651401074412730727143709940798183613501710';
|
||||||
|
|
||||||
|
const result = await encodeStandardSeedQR(mnemonic);
|
||||||
|
expect(result).toBe(expectedDigitStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes 12-word seed to correct Standard and Compact SeedQR (Test Vector 4)', async () => {
|
||||||
|
const mnemonic =
|
||||||
|
'forum undo fragile fade shy sign arrest garment culture tube off merit';
|
||||||
|
|
||||||
|
const expectedStandardDigitStream =
|
||||||
|
'073318950739065415961602009907670428187212261116';
|
||||||
|
|
||||||
|
const expectedCompactBitStream = '01011011101111011001110101110001101010001110110001111001100100001000001100011010111111110011010110011101010000100110010101000101';
|
||||||
|
|
||||||
|
const standard = await encodeStandardSeedQR(mnemonic);
|
||||||
|
expect(standard).toBe(expectedStandardDigitStream);
|
||||||
|
|
||||||
|
const compactEntropy = await encodeCompactSeedQREntropy(mnemonic);
|
||||||
|
const bitString = Array.from(compactEntropy)
|
||||||
|
.map((byte) => byte.toString(2).padStart(8, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
expect(bitString).toBe(expectedCompactBitStream);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
src/lib/seedqr.ts
Normal file
111
src/lib/seedqr.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* @file seedqr.ts
|
||||||
|
* @summary Implements encoding and decoding for Seedsigner's SeedQR format.
|
||||||
|
* @description This module provides functions to convert BIP39 mnemonics to and from the
|
||||||
|
* SeedQR format, supporting both the Standard (numeric) and Compact (hex) variations.
|
||||||
|
* The logic is adapted from the official Seedsigner specification and test vectors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BIP39_WORDLIST, WORD_INDEX, mnemonicToEntropy, entropyToMnemonic } from './seedblend';
|
||||||
|
|
||||||
|
// Helper to convert a hex string to a Uint8Array in a browser-compatible way.
|
||||||
|
function hexToUint8Array(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) {
|
||||||
|
throw new Error('Hex string must have an even number of characters');
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a Standard SeedQR (numeric digit stream) into a mnemonic phrase.
|
||||||
|
* @param digitStream A string containing 4-digit numbers representing BIP39 word indices.
|
||||||
|
* @returns The decoded BIP39 mnemonic.
|
||||||
|
*/
|
||||||
|
function decodeStandardSeedQR(digitStream: string): string {
|
||||||
|
if (digitStream.length % 4 !== 0) {
|
||||||
|
throw new Error('Invalid Standard SeedQR: Length must be a multiple of 4.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordIndices: number[] = [];
|
||||||
|
for (let i = 0; i < digitStream.length; i += 4) {
|
||||||
|
const indexStr = digitStream.slice(i, i + 4);
|
||||||
|
const index = parseInt(indexStr, 10);
|
||||||
|
if (isNaN(index) || index >= 2048) {
|
||||||
|
throw new Error(`Invalid word index in SeedQR: ${indexStr}`);
|
||||||
|
}
|
||||||
|
wordIndices.push(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordIndices.length !== 12 && wordIndices.length !== 24) {
|
||||||
|
throw new Error(`Invalid word count from SeedQR: ${wordIndices.length}. Must be 12 or 24.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mnemonicWords = wordIndices.map(index => BIP39_WORDLIST[index]);
|
||||||
|
return mnemonicWords.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a Compact SeedQR (hex-encoded entropy) into a mnemonic phrase.
|
||||||
|
* @param hexEntropy The hex-encoded entropy string.
|
||||||
|
* @returns A promise that resolves to the decoded BIP39 mnemonic.
|
||||||
|
*/
|
||||||
|
async function decodeCompactSeedQR(hexEntropy: string): Promise<string> {
|
||||||
|
const entropy = hexToUint8Array(hexEntropy);
|
||||||
|
if (entropy.length !== 16 && entropy.length !== 32) {
|
||||||
|
throw new Error(`Invalid entropy length for Compact SeedQR: ${entropy.length}. Must be 16 or 32 bytes.`);
|
||||||
|
}
|
||||||
|
return entropyToMnemonic(entropy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unified decoder that automatically detects and parses a SeedQR string.
|
||||||
|
* @param qrData The raw data from the QR code.
|
||||||
|
* @returns A promise that resolves to the decoded BIP39 mnemonic.
|
||||||
|
*/
|
||||||
|
export async function decodeSeedQR(qrData: string): Promise<string> {
|
||||||
|
const trimmed = qrData.trim();
|
||||||
|
// Standard SeedQR is a string of only digits.
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
return decodeStandardSeedQR(trimmed);
|
||||||
|
}
|
||||||
|
// Compact SeedQR is a hex string.
|
||||||
|
if (/^[0-9a-fA-F]+$/.test(trimmed)) {
|
||||||
|
return decodeCompactSeedQR(trimmed);
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported or invalid SeedQR format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a mnemonic into the Standard SeedQR format (numeric digit stream).
|
||||||
|
* @param mnemonic The BIP39 mnemonic string.
|
||||||
|
* @returns A promise that resolves to the Standard SeedQR string.
|
||||||
|
*/
|
||||||
|
export async function encodeStandardSeedQR(mnemonic: string): Promise<string> {
|
||||||
|
const words = mnemonic.trim().toLowerCase().split(/\s+/);
|
||||||
|
if (words.length !== 12 && words.length !== 24) {
|
||||||
|
throw new Error("Mnemonic must be 12 or 24 words to generate a SeedQR.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const digitStream = words.map(word => {
|
||||||
|
const index = WORD_INDEX.get(word);
|
||||||
|
if (index === undefined) {
|
||||||
|
throw new Error(`Invalid word in mnemonic: ${word}`);
|
||||||
|
}
|
||||||
|
return index.toString().padStart(4, '0');
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return digitStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a mnemonic into the Compact SeedQR format (raw entropy bytes).
|
||||||
|
* @param mnemonic The BIP39 mnemonic string.
|
||||||
|
* @returns A promise that resolves to the Compact SeedQR entropy as a Uint8Array.
|
||||||
|
*/
|
||||||
|
export async function encodeCompactSeedQREntropy(mnemonic: string): Promise<Uint8Array> {
|
||||||
|
return await mnemonicToEntropy(mnemonic);
|
||||||
|
}
|
||||||
205
src/lib/sessionCrypto.ts
Normal file
205
src/lib/sessionCrypto.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* @file Ephemeral, per-session, in-memory encryption using Web Crypto API.
|
||||||
|
*
|
||||||
|
* This module manages a single, non-exportable AES-GCM key for a user's session.
|
||||||
|
* It's designed to encrypt sensitive data (like a mnemonic) before it's placed
|
||||||
|
* into React state, mitigating the risk of plaintext data in memory snapshots.
|
||||||
|
* The key is destroyed when the user navigates away or the session ends.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Helper functions for encoding ---
|
||||||
|
|
||||||
|
function base64ToBytes(base64: string): Uint8Array {
|
||||||
|
const binString = atob(base64);
|
||||||
|
return Uint8Array.from(binString, (m) => m.codePointAt(0)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
const binString = Array.from(bytes, (byte) =>
|
||||||
|
String.fromCodePoint(byte),
|
||||||
|
).join("");
|
||||||
|
return btoa(binString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Module-level state ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the session's AES-GCM key. This variable is not exported and is
|
||||||
|
* only accessible through the functions in this module.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
let sessionKey: CryptoKey | null = null;
|
||||||
|
const KEY_ALGORITHM = 'AES-GCM';
|
||||||
|
const KEY_LENGTH = 256;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object containing encrypted data and necessary metadata for decryption.
|
||||||
|
*/
|
||||||
|
export interface EncryptedBlob {
|
||||||
|
v: 1;
|
||||||
|
/**
|
||||||
|
* The algorithm used. This is metadata; the actual Web Crypto API call
|
||||||
|
* uses `{ name: "AES-GCM", length: 256 }`.
|
||||||
|
*/
|
||||||
|
alg: 'A256GCM';
|
||||||
|
iv_b64: string; // Initialization Vector (base64)
|
||||||
|
ct_b64: string; // Ciphertext (base64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Core API Functions ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates and stores a session-level AES-GCM 256-bit key.
|
||||||
|
* The key is non-exportable and is held in a private module-level variable.
|
||||||
|
* If a key already exists, the existing key is returned, making the function idempotent.
|
||||||
|
* This function must be called before any encryption or decryption can occur.
|
||||||
|
* @returns A promise that resolves to the generated or existing CryptoKey.
|
||||||
|
*/
|
||||||
|
export async function getSessionKey(): Promise<CryptoKey> {
|
||||||
|
if (sessionKey) {
|
||||||
|
return sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: KEY_ALGORITHM,
|
||||||
|
length: KEY_LENGTH,
|
||||||
|
},
|
||||||
|
false, // non-exportable
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
sessionKey = key;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a JSON-serializable object using the current session key.
|
||||||
|
* @param data The object to encrypt. Must be JSON-serializable.
|
||||||
|
* @returns A promise that resolves to an EncryptedBlob.
|
||||||
|
*/
|
||||||
|
export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
|
||||||
|
if (!sessionKey) {
|
||||||
|
throw new Error('Session key not initialized. Call getSessionKey() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV is recommended for AES-GCM
|
||||||
|
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
||||||
|
|
||||||
|
const ciphertext = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: KEY_ALGORITHM,
|
||||||
|
iv: new Uint8Array(iv),
|
||||||
|
},
|
||||||
|
sessionKey,
|
||||||
|
plaintext,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
alg: 'A256GCM',
|
||||||
|
iv_b64: bytesToBase64(iv),
|
||||||
|
ct_b64: bytesToBase64(new Uint8Array(ciphertext)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts an EncryptedBlob back into its original object form.
|
||||||
|
* @param blob The EncryptedBlob to decrypt.
|
||||||
|
* @returns A promise that resolves to the original decrypted object.
|
||||||
|
*/
|
||||||
|
export async function decryptBlobToJson<T>(blob: EncryptedBlob): Promise<T> {
|
||||||
|
if (!sessionKey) {
|
||||||
|
throw new Error('Session key not initialized or has been destroyed.');
|
||||||
|
}
|
||||||
|
if (blob.v !== 1 || blob.alg !== 'A256GCM') {
|
||||||
|
throw new Error('Invalid or unsupported encrypted blob format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = base64ToBytes(blob.iv_b64);
|
||||||
|
const ciphertext = base64ToBytes(blob.ct_b64);
|
||||||
|
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: KEY_ALGORITHM,
|
||||||
|
iv: new Uint8Array(iv),
|
||||||
|
},
|
||||||
|
sessionKey,
|
||||||
|
new Uint8Array(ciphertext),
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonString = new TextDecoder().decode(decrypted);
|
||||||
|
return JSON.parse(jsonString) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the session key reference, making it unavailable for future
|
||||||
|
* operations and allowing it to be garbage collected.
|
||||||
|
*/
|
||||||
|
export function destroySessionKey(): void {
|
||||||
|
sessionKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A standalone test function that can be run in the browser console
|
||||||
|
* to verify the complete encryption and decryption lifecycle.
|
||||||
|
*
|
||||||
|
* To use:
|
||||||
|
* 1. Copy this entire function into the browser's developer console.
|
||||||
|
* 2. Run it by typing: `await runSessionCryptoTest()`
|
||||||
|
* 3. Check the console for logs.
|
||||||
|
*/
|
||||||
|
export async function runSessionCryptoTest(): Promise<void> {
|
||||||
|
console.log('--- Running Session Crypto Test ---');
|
||||||
|
try {
|
||||||
|
// 1. Destroy any old key
|
||||||
|
destroySessionKey();
|
||||||
|
console.log('Old key destroyed (if any).');
|
||||||
|
|
||||||
|
// 2. Generate a new key
|
||||||
|
await getSessionKey();
|
||||||
|
console.log('New session key generated.');
|
||||||
|
|
||||||
|
// 3. Define a secret object
|
||||||
|
const originalObject = {
|
||||||
|
mnemonic: 'fee table visa input phrase lake buffalo vague merit million mesh blend',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
console.log('Original object:', originalObject);
|
||||||
|
|
||||||
|
// 4. Encrypt the object
|
||||||
|
const encrypted = await encryptJsonToBlob(originalObject);
|
||||||
|
console.log('Encrypted blob:', encrypted);
|
||||||
|
if (typeof encrypted.ct_b64 !== 'string' || encrypted.ct_b64.length < 20) {
|
||||||
|
throw new Error('Encryption failed: ciphertext looks invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Decrypt the object
|
||||||
|
const decrypted = await decryptBlobToJson(encrypted);
|
||||||
|
console.log('Decrypted object:', decrypted);
|
||||||
|
|
||||||
|
// 6. Verify integrity
|
||||||
|
if (JSON.stringify(originalObject) !== JSON.stringify(decrypted)) {
|
||||||
|
throw new Error('Verification failed: Decrypted data does not match original data.');
|
||||||
|
}
|
||||||
|
console.log('%c✅ Success: Data integrity verified.', 'color: green; font-weight: bold;');
|
||||||
|
|
||||||
|
// 7. Test key destruction
|
||||||
|
destroySessionKey();
|
||||||
|
console.log('Session key destroyed.');
|
||||||
|
try {
|
||||||
|
await decryptBlobToJson(encrypted);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('As expected, decryption failed after key destruction:', (e as Error).message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('%c❌ Test Failed:', 'color: red; font-weight: bold;', error);
|
||||||
|
} finally {
|
||||||
|
console.log('--- Test Complete ---');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For convenience, attach the test runner to the window object.
|
||||||
|
// This is for development/testing only and can be removed in production.
|
||||||
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
|
(window as any).runSessionCryptoTest = runSessionCryptoTest;
|
||||||
|
}
|
||||||
@@ -11,4 +11,42 @@ export type ParsedSeedPgpFrame = {
|
|||||||
kind: "single";
|
kind: "single";
|
||||||
crc16: string;
|
crc16: string;
|
||||||
b45: string;
|
b45: string;
|
||||||
|
rawPayload?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Krux KEF types
|
||||||
|
export type KruxEncryptionParams = {
|
||||||
|
label?: string;
|
||||||
|
iterations?: number;
|
||||||
|
version?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EncryptionMode = 'pgp' | 'krux' | 'seedqr' | 'text';
|
||||||
|
|
||||||
|
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 | Uint8Array;
|
||||||
|
pgpBytes?: Uint8Array;
|
||||||
|
kefBytes?: Uint8Array; // Added for Krux binary output
|
||||||
|
recipientFingerprint?: string;
|
||||||
|
label?: string;
|
||||||
|
version?: number;
|
||||||
|
iterations?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
26
src/main.tsx
26
src/main.tsx
@@ -1,8 +1,34 @@
|
|||||||
|
import './polyfills';
|
||||||
|
|
||||||
|
// Suppress OpenPGP.js AES cipher warnings
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
console.warn = (...args: any[]) => {
|
||||||
|
const msg = args[0]?.toString() || '';
|
||||||
|
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalWarn.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args: any[]) => {
|
||||||
|
const msg = args[0]?.toString() || '';
|
||||||
|
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalError.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
await import('./lib/sessionCrypto');
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
7
src/polyfills.ts
Normal file
7
src/polyfills.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
|
// Make Buffer available globally for libraries that expect it
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any).Buffer = Buffer;
|
||||||
|
(window as any).global = window;
|
||||||
|
}
|
||||||
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
@@ -6,3 +6,6 @@ declare module '*.css' {
|
|||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __BUILD_HASH__: string;
|
||||||
|
declare const __BUILD_TIMESTAMP__: string;
|
||||||
|
|||||||
@@ -1,18 +1,59 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import basicSsl from '@vitejs/plugin-basic-ssl'
|
||||||
|
import wasm from 'vite-plugin-wasm'
|
||||||
|
import topLevelAwait from 'vite-plugin-top-level-await'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
// Read version from package.json
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
|
||||||
|
const appVersion = packageJson.version
|
||||||
|
|
||||||
|
// Get git commit hash
|
||||||
|
const gitHash = execSync('git rev-parse --short HEAD').toString().trim()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
base: '/seedpgp-web-app/', // Match your repo name
|
wasm(),
|
||||||
|
topLevelAwait(),
|
||||||
|
basicSsl(),
|
||||||
|
react(),
|
||||||
|
{
|
||||||
|
name: 'html-transform',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(/__APP_VERSION__/g, appVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
https: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
buffer: 'buffer',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
define: {
|
||||||
|
global: 'globalThis'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
base: '/', // Always use root, since we're Cloudflare Pages only
|
||||||
|
publicDir: 'public', // ← Explicitly set (should be default)
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 600, // Suppress warning
|
outDir: 'dist',
|
||||||
rollupOptions: {
|
emptyOutDir: false,
|
||||||
output: {
|
},
|
||||||
manualChunks: {
|
define: {
|
||||||
'openpgp': ['openpgp'], // Separate chunk for PGP
|
'__APP_VERSION__': JSON.stringify(appVersion),
|
||||||
}
|
'__BUILD_HASH__': JSON.stringify(gitHash),
|
||||||
}
|
'__BUILD_TIMESTAMP__': JSON.stringify(new Date().toISOString()),
|
||||||
}
|
'global': 'globalThis',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user