mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67a2159f2 | ||
|
|
ab1f35ce80 | ||
|
|
586eabc361 | ||
|
|
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
REFERENCE
|
||||
.Ref
|
||||
291
DEVELOPMENT.md
291
DEVELOPMENT.md
@@ -1,291 +0,0 @@
|
||||
Here's your `DEVELOPMENT.md`:
|
||||
|
||||
```markdown
|
||||
# Development Guide - SeedPGP v1.4.4
|
||||
|
||||
## 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! 🚀📋
|
||||
33
GEMINI.md
33
GEMINI.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
**SeedPGP v1.4.4**: Client-side BIP39 mnemonic encryption webapp
|
||||
**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/>
|
||||
@@ -12,8 +12,7 @@
|
||||
1. **Security-first**: Never persist secrets (mnemonic/passphrase/private keys) to localStorage/sessionStorage/IndexedDB
|
||||
2. **Small PRs**: Max 1-5 files per feature; propose plan before coding
|
||||
3. **Client-side only**: No backend; all crypto runs in browser (Web Crypto API + OpenPGP.js)
|
||||
4. **GitHub Pages deploy**: Base path `/seedpgp-web-app/` configured in vite.config.ts
|
||||
5. **Honest security claims**: Don't overclaim what client-side JS can guarantee
|
||||
4. **Honest security claims**: Don't overclaim what client-side JS can guarantee
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
@@ -30,7 +29,7 @@
|
||||
### Entry Points
|
||||
|
||||
- `src/main.tsx` → `src/App.tsx` (main application)
|
||||
- Build output: `dist/` (separate git repo for GitHub Pages deployment)
|
||||
- Build output: `dist/`
|
||||
|
||||
### Directory Structure
|
||||
|
||||
@@ -125,7 +124,6 @@ bun run dev # Dev server (localhost:5173)
|
||||
bun run build # Build to dist/
|
||||
bun run typecheck # TypeScript validation (tsc --noEmit)
|
||||
bun run preview # Preview production build
|
||||
./scripts/deploy.sh v1.x.x # Build + push to public repo
|
||||
```
|
||||
|
||||
### Deployment Process
|
||||
@@ -140,14 +138,6 @@ bun run preview # Preview production build
|
||||
3. **Output directory:** `dist/`
|
||||
4. **Security headers:** Automatically enforced via `public/_headers`
|
||||
|
||||
### Benefits Over GitHub Pages
|
||||
|
||||
- ✅ Real CSP header enforcement (blocks network requests at browser level)
|
||||
- ✅ Custom security headers (X-Frame-Options, X-Content-Type-Options)
|
||||
- ✅ Auto-deploy on push to main
|
||||
- ✅ Build preview for PRs
|
||||
- ✅ Better performance (global CDN)
|
||||
|
||||
### Git Workflow
|
||||
|
||||
```bash
|
||||
@@ -155,7 +145,7 @@ bun run preview # Preview production build
|
||||
git add src/
|
||||
git commit -m "feat(v1.x): description"
|
||||
|
||||
# Tag version (triggers auto-deploy to Cloudflare)
|
||||
# Push to main branch (including tags) triggers auto-deploy to Cloudflare
|
||||
git tag v1.x.x
|
||||
git push origin main --tags
|
||||
|
||||
@@ -167,9 +157,6 @@ git push origin main --tags
|
||||
# Then commit the README update:
|
||||
git add README.md
|
||||
git commit -m "docs: update README for v1.x.x"
|
||||
|
||||
# Deploy to GitHub Pages
|
||||
./scripts/deploy.sh v1.x.x
|
||||
```
|
||||
|
||||
---
|
||||
@@ -282,7 +269,6 @@ Before implementing any feature:
|
||||
### Security Claims
|
||||
|
||||
- Don't claim "RAM is wiped" (JavaScript can't force GC)
|
||||
- Don't claim "offline mode" without real CSP headers (GitHub Pages can't set custom headers)
|
||||
- Don't promise protection against active browser compromise (XSS/extensions)
|
||||
|
||||
### Storage
|
||||
@@ -314,13 +300,12 @@ await window.runSessionCryptoTest()
|
||||
|
||||
---
|
||||
|
||||
## Current Version: v1.4.4
|
||||
## Current Version: v1.4.5
|
||||
|
||||
**Recent Changes (v1.4.4):**
|
||||
- Enhanced security documentation with explicit threat model
|
||||
- Improved README with simple examples and best practices
|
||||
- Better air-gapped usage guidance for maximum security
|
||||
- Version bump with security audit improvements
|
||||
**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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SeedPGP v1.4.4
|
||||
# SeedPGP v1.4.5
|
||||
|
||||
**Secure BIP39 mnemonic backup using PGP encryption and QR codes**
|
||||
|
||||
@@ -378,6 +378,11 @@ seedpgp-web/
|
||||
|
||||
## 🔄 Version History
|
||||
|
||||
### 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`.
|
||||
|
||||
### v1.4.4 (2026-02-03)
|
||||
- ✅ **Enhanced security documentation** with explicit threat model
|
||||
- ✅ **Improved README** with simple examples and best practices
|
||||
|
||||
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",
|
||||
"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",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"openpgp": "^6.3.0",
|
||||
"pako": "^2.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tiny-secp256k1": "^2.2.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.6",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/qrcode-generator": "^1.0.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.2",
|
||||
"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=="],
|
||||
|
||||
"@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.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=="],
|
||||
|
||||
"@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-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=="],
|
||||
|
||||
"@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__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/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/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/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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
|
||||
|
||||
"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=="],
|
||||
@@ -358,6 +432,8 @@
|
||||
|
||||
"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-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-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="],
|
||||
|
||||
"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=="],
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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()
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<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, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>SeedPGP v__APP_VERSION__</title>
|
||||
</head>
|
||||
|
||||
|
||||
20
package.json
20
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "seedpgp-web",
|
||||
"private": true,
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -10,24 +10,36 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"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",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"openpgp": "^6.3.0",
|
||||
"pako": "^2.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"tiny-secp256k1": "^2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.6",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/qrcode-generator": "^1.0.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
121
public/README.md
121
public/README.md
@@ -1,121 +0,0 @@
|
||||
# SeedPGP Web App
|
||||
|
||||
**Secure BIP39 mnemonic backup tool using OpenPGP encryption**
|
||||
|
||||
🔗 **Live App**: https://kccleoc.github.io/seedpgp-web-app/
|
||||
|
||||
## About
|
||||
|
||||
Client-side web application for encrypting cryptocurrency seed phrases (BIP39 mnemonics) using OpenPGP encryption with QR code generation and scanning capabilities.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- 🔐 **OpenPGP Encryption** - Curve25519Legacy (cv25519) encryption
|
||||
- 📱 **QR Code Generation** - High-quality 512x512px PNG with download
|
||||
- 📸 **QR Code Scanner** - Camera or image upload with live preview
|
||||
- 🔄 **Round-trip Flow** - Encrypt → QR → Scan → Decrypt seamlessly
|
||||
- ✅ **BIP39 Support** - 12/18/24-word mnemonics with optional passphrase
|
||||
- 🔒 **Symmetric Encryption** - Optional password-only encryption (SKESK)
|
||||
- 🎯 **CRC16 Validation** - Frame integrity checking
|
||||
- 📦 **Base45 Encoding** - Compact QR-friendly format (RFC 9285)
|
||||
- 🌐 **100% Client-Side** - No backend, no data transmission
|
||||
|
||||
## 🔒 Security Notice
|
||||
|
||||
⚠️ **Your private keys and seed phrases never leave your browser**
|
||||
|
||||
- Static web app with **no backend server**
|
||||
- All cryptographic operations run **locally in your browser**
|
||||
- **No data transmitted** to any server
|
||||
- Camera access requires **HTTPS or localhost**
|
||||
- Always verify you're on the correct URL before use
|
||||
|
||||
### For Maximum Security
|
||||
|
||||
For production use with real funds:
|
||||
- 🏠 Download and run locally (\`bun run dev\`)
|
||||
- 🔐 Use on airgapped device
|
||||
- 📥 Self-host on your own domain
|
||||
- 🔍 Source code: https://github.com/kccleoc/seedpgp-web (private)
|
||||
|
||||
## 📖 How to Use
|
||||
|
||||
### Backup Flow
|
||||
1. **Enter** your 12/24-word BIP39 mnemonic
|
||||
2. **Add** PGP public key and/or message password (optional)
|
||||
3. **Generate** encrypted QR code
|
||||
4. **Download** or scan QR code for backup
|
||||
|
||||
### Restore Flow
|
||||
1. **Scan QR Code** using camera or upload image
|
||||
2. **Provide** private key and/or message password
|
||||
3. **Decrypt** to recover your mnemonic
|
||||
|
||||
### QR Scanner Features
|
||||
- 📷 **Camera Mode** - Live scanning with environment camera (iPhone Continuity Camera supported on macOS)
|
||||
- 📁 **Upload Mode** - Scan from saved images or screenshots
|
||||
- ✅ **Auto-validation** - Verifies SEEDPGP1 format before accepting
|
||||
|
||||
## 🛠 Technical Stack
|
||||
|
||||
- **TypeScript** - Type-safe development
|
||||
- **React 18** - Modern UI framework
|
||||
- **Vite 6** - Lightning-fast build tool
|
||||
- **OpenPGP.js v6** - RFC 4880 compliant encryption
|
||||
- **html5-qrcode** - QR scanning library
|
||||
- **TailwindCSS** - Utility-first styling
|
||||
- **Lucide React** - Beautiful icons
|
||||
|
||||
## 📋 Protocol Format
|
||||
|
||||
\`\`\`
|
||||
SEEDPGP1:0:ABCD:BASE45DATA
|
||||
|
||||
SEEDPGP1 - Protocol identifier + version
|
||||
0 - Frame number (single frame)
|
||||
ABCD - CRC16-CCITT-FALSE checksum
|
||||
BASE45 - Base45-encoded OpenPGP binary message
|
||||
\`\`\`
|
||||
|
||||
## 🔐 Encryption Details
|
||||
|
||||
- **Algorithm**: AES-256 (preferred symmetric cipher)
|
||||
- **Curve**: Curve25519Legacy for modern security
|
||||
- **Key Format**: OpenPGP RFC 4880 compliant
|
||||
- **Error Correction**: QR Level M (15% recovery)
|
||||
- **Integrity**: CRC16-CCITT-FALSE frame validation
|
||||
|
||||
## 📱 Browser Compatibility
|
||||
|
||||
- ✅ Chrome/Edge (latest)
|
||||
- ✅ Safari 16+ (macOS/iOS)
|
||||
- ✅ Firefox (latest)
|
||||
- 📷 Camera requires HTTPS or localhost
|
||||
|
||||
## 📦 Version
|
||||
|
||||
**Current deployment: v1.2.0**
|
||||
|
||||
### Changelog
|
||||
|
||||
#### v1.2.0 (2026-01-29)
|
||||
- ✨ Added QR scanner with camera/upload support
|
||||
- 📥 Added QR code download with auto-naming
|
||||
- 🔧 Split state for backup/restore tabs
|
||||
- 🎨 Improved QR generation quality
|
||||
- 🐛 Fixed Safari camera permissions
|
||||
- 📱 Added Continuity Camera support
|
||||
|
||||
#### v1.1.0 (2026-01-28)
|
||||
- 🎉 Initial public release
|
||||
- 🔐 OpenPGP encryption/decryption
|
||||
- 📱 QR code generation
|
||||
- ✅ BIP39 validation
|
||||
|
||||
---
|
||||
|
||||
**Last updated**: 2026-01-29
|
||||
|
||||
**Built with** ❤️ using TypeScript, React, Vite, and OpenPGP.js
|
||||
|
||||
**License**: Private source code - deployment only
|
||||
@@ -1,6 +0,0 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; form-action 'none'; base-uri 'self';
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
@@ -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 |
815
src/App.tsx
815
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
609
src/components/CameraEntropy.tsx
Normal file
609
src/components/CameraEntropy.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Camera, X, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { InteractionEntropy } from '../lib/interactionEntropy';
|
||||
|
||||
interface EntropyStats {
|
||||
shannon: number;
|
||||
variance: number;
|
||||
uniqueColors: number;
|
||||
brightnessRange: [number, number];
|
||||
rgbStats: {
|
||||
r: { mean: number; stddev: number };
|
||||
g: { mean: number; stddev: number };
|
||||
b: { mean: number; stddev: number };
|
||||
};
|
||||
histogram: number[]; // 10 buckets
|
||||
captureTimeMicros: number;
|
||||
interactionSamples: number;
|
||||
totalBits: number;
|
||||
dataSize: number;
|
||||
}
|
||||
|
||||
interface CameraEntropyProps {
|
||||
wordCount: 12 | 24;
|
||||
onEntropyGenerated: (mnemonic: string, stats: EntropyStats) => void;
|
||||
onCancel: () => void;
|
||||
interactionEntropy: InteractionEntropy;
|
||||
}
|
||||
|
||||
const CameraEntropy: React.FC<CameraEntropyProps> = ({
|
||||
wordCount,
|
||||
onEntropyGenerated,
|
||||
onCancel,
|
||||
interactionEntropy
|
||||
}) => {
|
||||
const [step, setStep] = useState<'permission' | 'capture' | 'processing' | 'stats'>('permission');
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [entropy, setEntropy] = useState(0);
|
||||
const [variance, setVariance] = useState(0);
|
||||
const [captureEnabled, setCaptureEnabled] = useState(false);
|
||||
const [stats, setStats] = useState<EntropyStats | null>(null);
|
||||
const [generatedMnemonic, setGeneratedMnemonic] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>();
|
||||
|
||||
const requestCameraAccess = async () => {
|
||||
try {
|
||||
console.log('🎥 Requesting camera access...');
|
||||
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: false
|
||||
});
|
||||
|
||||
console.log('✅ Camera stream obtained:', {
|
||||
tracks: mediaStream.getVideoTracks().map(t => ({
|
||||
label: t.label,
|
||||
enabled: t.enabled,
|
||||
readyState: t.readyState,
|
||||
settings: t.getSettings()
|
||||
}))
|
||||
});
|
||||
|
||||
setStream(mediaStream);
|
||||
setStep('capture');
|
||||
|
||||
// Don't set up video here - let useEffect handle it after render
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('❌ Camera access error:', err.name, err.message, err);
|
||||
setError(`Camera unavailable: ${err.message}`);
|
||||
setTimeout(() => onCancel(), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up video element when stream is available
|
||||
useEffect(() => {
|
||||
if (!stream || !videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
console.log('📹 Setting up video element with stream...');
|
||||
|
||||
video.srcObject = stream;
|
||||
video.setAttribute('playsinline', '');
|
||||
video.setAttribute('autoplay', '');
|
||||
video.muted = true;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
console.log('✅ Video metadata loaded:', {
|
||||
videoWidth: video.videoWidth,
|
||||
videoHeight: video.videoHeight,
|
||||
readyState: video.readyState
|
||||
});
|
||||
|
||||
video.play()
|
||||
.then(() => {
|
||||
console.log('✅ Video playing:', {
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime
|
||||
});
|
||||
|
||||
// Wait for actual frame data
|
||||
setTimeout(() => {
|
||||
// Test if video is actually rendering
|
||||
const testCanvas = document.createElement('canvas');
|
||||
testCanvas.width = video.videoWidth;
|
||||
testCanvas.height = video.videoHeight;
|
||||
const testCtx = testCanvas.getContext('2d');
|
||||
|
||||
if (testCtx && video.videoWidth > 0 && video.videoHeight > 0) {
|
||||
testCtx.drawImage(video, 0, 0);
|
||||
const imageData = testCtx.getImageData(0, 0, Math.min(10, video.videoWidth), Math.min(10, video.videoHeight));
|
||||
const pixels = Array.from(imageData.data.slice(0, 40));
|
||||
console.log('🎨 First 40 pixel values:', pixels);
|
||||
|
||||
const allZero = pixels.every(p => p === 0);
|
||||
const allSame = pixels.every(p => p === pixels[0]);
|
||||
|
||||
if (allZero) {
|
||||
console.error('❌ All pixels are zero - video not rendering!');
|
||||
} else if (allSame) {
|
||||
console.warn('⚠️ All pixels same value - possible issue');
|
||||
} else {
|
||||
console.log('✅ Video has actual frame data');
|
||||
}
|
||||
}
|
||||
|
||||
startEntropyAnalysis();
|
||||
}, 300);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('❌ video.play() failed:', err);
|
||||
setError('Failed to start video preview: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoError = (err: any) => {
|
||||
console.error('❌ Video element error:', err);
|
||||
setError('Video playback error');
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('error', handleVideoError);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('error', handleVideoError);
|
||||
};
|
||||
}, [stream]); // Run when stream changes
|
||||
|
||||
const startEntropyAnalysis = () => {
|
||||
console.log('🔍 Starting entropy analysis...');
|
||||
|
||||
const analyze = () => {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!video || !canvas) {
|
||||
// If we are in processing/stats step, don't warn, just stop
|
||||
// This prevents race conditions during capture
|
||||
return;
|
||||
}
|
||||
|
||||
// Critical: Wait for valid dimensions
|
||||
if (video.videoWidth === 0 || video.videoHeight === 0) {
|
||||
console.warn('⚠️ Video dimensions are 0, waiting...', {
|
||||
videoWidth: video.videoWidth,
|
||||
videoHeight: video.videoHeight,
|
||||
readyState: video.readyState
|
||||
});
|
||||
animationRef.current = requestAnimationFrame(analyze);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
console.error('❌ Failed to get canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to match video
|
||||
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
console.log('📐 Canvas resized to:', canvas.width, 'x', canvas.height);
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.drawImage(video, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Check if we got actual data
|
||||
if (imageData.data.length === 0) {
|
||||
console.error('❌ ImageData is empty');
|
||||
animationRef.current = requestAnimationFrame(analyze);
|
||||
return;
|
||||
}
|
||||
|
||||
const { entropy: e, variance: v } = calculateQuickEntropy(imageData);
|
||||
|
||||
setEntropy(e);
|
||||
setVariance(v);
|
||||
setCaptureEnabled(e >= 7.5 && v >= 1000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Error in entropy analysis:', err);
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(analyze);
|
||||
};
|
||||
|
||||
analyze();
|
||||
};
|
||||
|
||||
const calculateQuickEntropy = (imageData: ImageData): { entropy: number; variance: number } => {
|
||||
const data = imageData.data;
|
||||
const histogram = new Array(256).fill(0);
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
// Sample every 16th pixel for performance
|
||||
for (let i = 0; i < data.length; i += 16) {
|
||||
const gray = Math.floor((data[i] + data[i + 1] + data[i + 2]) / 3);
|
||||
histogram[gray]++;
|
||||
sum += gray;
|
||||
count++;
|
||||
}
|
||||
|
||||
const mean = sum / count;
|
||||
|
||||
// Shannon entropy
|
||||
let entropy = 0;
|
||||
for (const h_count of histogram) {
|
||||
if (h_count > 0) {
|
||||
const p = h_count / count;
|
||||
entropy -= p * Math.log2(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Variance
|
||||
let variance = 0;
|
||||
for (let i = 0; i < data.length; i += 16) {
|
||||
const gray = Math.floor((data[i] + data[i + 1] + data[i + 2]) / 3);
|
||||
variance += Math.pow(gray - mean, 2);
|
||||
}
|
||||
variance = variance / count;
|
||||
|
||||
return { entropy, variance };
|
||||
};
|
||||
|
||||
const captureEntropy = async () => {
|
||||
if (!videoRef.current || !canvasRef.current) return;
|
||||
|
||||
// CRITICAL: Stop the analysis loop immediately
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
console.log('🛑 Stopped entropy analysis loop');
|
||||
}
|
||||
|
||||
setStep('processing');
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = videoRef.current.videoWidth;
|
||||
canvas.height = videoRef.current.videoHeight;
|
||||
ctx.drawImage(videoRef.current, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const captureTime = performance.now();
|
||||
|
||||
// Full entropy analysis
|
||||
const fullStats = await calculateFullEntropy(imageData, captureTime);
|
||||
|
||||
// Generate mnemonic from entropy
|
||||
const mnemonic = await generateMnemonicFromEntropy(fullStats, wordCount, canvas);
|
||||
|
||||
setStats(fullStats);
|
||||
setStep('stats');
|
||||
|
||||
// Stop camera
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
console.log('📷 Camera stopped');
|
||||
}
|
||||
|
||||
// Don't call onEntropyGenerated yet - let user review stats first
|
||||
setGeneratedMnemonic(mnemonic);
|
||||
};
|
||||
|
||||
const calculateFullEntropy = async (
|
||||
imageData: ImageData,
|
||||
captureTime: number
|
||||
): Promise<EntropyStats> => {
|
||||
const data = imageData.data;
|
||||
const pixels = data.length / 4;
|
||||
|
||||
const r: number[] = [], g: number[] = [], b: number[] = [];
|
||||
const histogram = new Array(10).fill(0);
|
||||
const colorSet = new Set<number>();
|
||||
let minBright = 255, maxBright = 0;
|
||||
const allGray: number[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r.push(data[i]);
|
||||
g.push(data[i + 1]);
|
||||
b.push(data[i + 2]);
|
||||
|
||||
const brightness = Math.floor((data[i] + data[i + 1] + data[i + 2]) / 3);
|
||||
allGray.push(brightness);
|
||||
const bucket = Math.floor(brightness / 25.6);
|
||||
histogram[Math.min(bucket, 9)]++;
|
||||
|
||||
minBright = Math.min(minBright, brightness);
|
||||
maxBright = Math.max(maxBright, brightness);
|
||||
|
||||
const color = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
|
||||
colorSet.add(color);
|
||||
}
|
||||
|
||||
const grayHistogram = new Array(256).fill(0);
|
||||
for (const gray of allGray) {
|
||||
grayHistogram[gray]++;
|
||||
}
|
||||
|
||||
let shannon = 0;
|
||||
for (const count of grayHistogram) {
|
||||
if (count > 0) {
|
||||
const p = count / pixels;
|
||||
shannon -= p * Math.log2(p);
|
||||
}
|
||||
}
|
||||
|
||||
const calcStats = (arr: number[]): { mean: number; stddev: number } => {
|
||||
const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
const variance = arr.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / arr.length;
|
||||
return { mean, stddev: Math.sqrt(variance) };
|
||||
};
|
||||
|
||||
const rgbStats = { r: calcStats(r), g: calcStats(g), b: calcStats(b) };
|
||||
const variance = calcStats(allGray).stddev ** 2;
|
||||
|
||||
return {
|
||||
shannon,
|
||||
variance,
|
||||
uniqueColors: colorSet.size,
|
||||
brightnessRange: [minBright, maxBright],
|
||||
rgbStats,
|
||||
histogram,
|
||||
captureTimeMicros: Math.floor((captureTime % 1) * 1000000),
|
||||
interactionSamples: interactionEntropy.getSampleCount().total,
|
||||
totalBits: 256,
|
||||
dataSize: data.length
|
||||
};
|
||||
};
|
||||
|
||||
const generateMnemonicFromEntropy = async (
|
||||
stats: EntropyStats,
|
||||
wordCount: 12 | 24,
|
||||
canvas: HTMLCanvasElement
|
||||
): Promise<string> => {
|
||||
// Mix multiple entropy sources
|
||||
const imageDataUrl = canvas.toDataURL(); // Now canvas is guaranteed not null
|
||||
|
||||
const interactionBytes = await interactionEntropy.getEntropyBytes();
|
||||
const cryptoBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
|
||||
const combined = [
|
||||
imageDataUrl,
|
||||
stats.captureTimeMicros.toString(),
|
||||
Array.from(interactionBytes).join(','),
|
||||
Array.from(cryptoBytes).join(','),
|
||||
performance.now().toString()
|
||||
].join('|');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(combined);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
|
||||
// Use bip39 to generate mnemonic from the collected entropy hash
|
||||
const { entropyToMnemonic } = await import('bip39');
|
||||
const entropyLength = wordCount === 12 ? 16 : 32;
|
||||
const finalEntropy = new Uint8Array(hash).slice(0, entropyLength);
|
||||
|
||||
// The bip39 library expects a hex string or a Buffer.
|
||||
const entropyHex = Buffer.from(finalEntropy).toString('hex');
|
||||
|
||||
return entropyToMnemonic(entropyHex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, [stream]);
|
||||
|
||||
const getStatusMessage = () => {
|
||||
if (entropy >= 7.0 && variance >= 800) {
|
||||
return { icon: CheckCircle2, text: '✅ Excellent entropy - ready!', color: '#39ff14' };
|
||||
} else if (entropy >= 6.0 && variance >= 500) {
|
||||
return { icon: AlertCircle, text: '🟡 Good - point to brighter area', color: '#ffd700' };
|
||||
} else if (entropy >= 5.0) {
|
||||
return { icon: AlertCircle, text: '🟠 Low - find textured surface', color: '#ff9500' };
|
||||
} else {
|
||||
return { icon: AlertCircle, text: '🔴 Too low - point at lamp/pattern', color: '#ff006e' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{step === 'permission' && (
|
||||
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-4">
|
||||
<div className="text-center space-y-2">
|
||||
<Camera size={48} className="mx-auto text-[#00f0ff]" />
|
||||
<h3 className="text-sm font-bold text-[#00f0ff] uppercase">Camera Permission Needed</h3>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-[#6ef3f7]">
|
||||
<p>To generate entropy, we need:</p>
|
||||
<ul className="list-disc list-inside space-y-1 pl-2">
|
||||
<li>Camera access to capture pixel noise</li>
|
||||
<li>Image data processed locally</li>
|
||||
<li>Never stored or transmitted</li>
|
||||
<li>Camera auto-closes after use</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={requestCameraAccess} className="flex-1 py-2.5 bg-[#00f0ff] text-[#0a0a0f] rounded-lg font-bold text-sm hover:bg-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.5)] transition-all">Allow Camera</button>
|
||||
<button onClick={onCancel} className="flex-1 py-2.5 bg-[#16213e] border-2 border-[#ff006e] text-[#ff006e] rounded-lg font-bold text-sm hover:bg-[#ff006e]/20 transition-all">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'capture' && (
|
||||
<div className="space-y-4">
|
||||
<div className="relative rounded-xl overflow-hidden border-2 border-[#00f0ff]/30 bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
autoPlay
|
||||
muted
|
||||
className="w-full"
|
||||
style={{
|
||||
maxHeight: '300px',
|
||||
objectFit: 'cover',
|
||||
border: '2px solid #00f0ff',
|
||||
backgroundColor: '#000'
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="hidden"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-3">
|
||||
<div className="text-xs text-[#6ef3f7] space-y-1">
|
||||
<p className="font-bold text-[#00f0ff]">Instructions:</p>
|
||||
<p>Point camera at bright, textured surface (lamp, carpet, wall with pattern)</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[#00f0ff]">Entropy Quality:</span>
|
||||
<span className="font-mono text-[#00f0ff]">{entropy.toFixed(2)}/8.0</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#0a0a0f] rounded-full h-2 overflow-hidden">
|
||||
<div className="h-full transition-all" style={{ width: `${(entropy / 8) * 100}%`, backgroundColor: getStatusMessage().color }} />
|
||||
</div>
|
||||
<div className="text-xs font-medium" style={{ color: getStatusMessage().color }}>{getStatusMessage().text}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={captureEntropy} disabled={!captureEnabled} className="flex-1 py-2.5 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-lg font-bold uppercase disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all">
|
||||
<Camera className="inline mr-2" size={16} />Capture
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2.5 bg-[#16213e] border-2 border-[#ff006e] text-[#ff006e] rounded-lg font-bold text-sm hover:bg-[#ff006e]/20 transition-all"><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'processing' && (
|
||||
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 text-center space-y-3">
|
||||
<div className="animate-spin mx-auto w-12 h-12 border-4 border-[#00f0ff]/30 border-t-[#00f0ff] rounded-full" />
|
||||
<p className="text-sm text-[#00f0ff]">Processing entropy...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'stats' && stats && (
|
||||
<div className="p-4 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_30px_rgba(57,255,20,0.3)] space-y-4">
|
||||
<div className="flex items-center gap-2 text-[#39ff14]"><CheckCircle2 size={24} /><h3 className="text-sm font-bold uppercase">Entropy Analysis</h3></div>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div><p className="text-[#00f0ff] font-bold mb-1">Primary Source:</p><p className="text-[#6ef3f7]">Camera Sensor Noise</p></div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">RANDOMNESS METRICS:</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-[10px]">
|
||||
<div>Shannon Entropy:</div><div className="text-[#39ff14]">{stats.shannon.toFixed(2)}/8.00</div>
|
||||
<div>Pixel Variance:</div><div className="text-[#39ff14]">{stats.variance.toFixed(1)}</div>
|
||||
<div>Unique Colors:</div><div className="text-[#39ff14]">{stats.uniqueColors.toLocaleString()}</div>
|
||||
<div>Brightness Range:</div><div className="text-[#39ff14]">{stats.brightnessRange[0]}-{stats.brightnessRange[1]}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">RGB DISTRIBUTION:</p>
|
||||
<div className="space-y-1 font-mono text-[10px]">
|
||||
<div className="flex justify-between"><span>Red:</span><span className="text-[#ff6b6b]">μ={stats.rgbStats.r.mean.toFixed(0)} σ={stats.rgbStats.r.stddev.toFixed(1)}</span></div>
|
||||
<div className="flex justify-between"><span>Green:</span><span className="text-[#51cf66]">μ={stats.rgbStats.g.mean.toFixed(0)} σ={stats.rgbStats.g.stddev.toFixed(1)}</span></div>
|
||||
<div className="flex justify-between"><span>Blue:</span><span className="text-[#339af0]">μ={stats.rgbStats.b.mean.toFixed(0)} σ={stats.rgbStats.b.stddev.toFixed(1)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">BRIGHTNESS HISTOGRAM:</p>
|
||||
<div className="flex items-end justify-between h-12 gap-0.5">{stats.histogram.map((val, i) => { const max = Math.max(...stats.histogram); const height = (val / max) * 100; return (<div key={i} className="flex-1 bg-[#00f0ff] rounded-t" style={{ height: `${height}%` }} />); })}</div>
|
||||
<div className="flex justify-between text-[9px] text-[#6ef3f7] mt-1"><span>Dark</span><span>Bright</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">TIMING ENTROPY:</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-[10px]">
|
||||
<div>Capture timing:</div><div className="text-[#39ff14]">...{stats.captureTimeMicros}μs</div>
|
||||
<div>Interaction samples:</div><div className="text-[#39ff14]">{stats.interactionSamples}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">MIXED WITH:</p>
|
||||
<div className="space-y-1 text-[#6ef3f7] text-[10px]">
|
||||
<div>- crypto.getRandomValues() ✓</div>
|
||||
<div>- performance.now() ✓</div>
|
||||
<div>- Mouse/keyboard timing ✓</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-[#00f0ff]/30">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[#00f0ff] font-bold">Total Entropy:</span>
|
||||
<span className="text-lg font-bold text-[#39ff14]">{stats.totalBits} bits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">HOW SEED IS GENERATED:</p>
|
||||
<div className="space-y-1 text-[10px] text-[#6ef3f7]">
|
||||
<div>1. Camera captures {stats.uniqueColors.toLocaleString()} unique pixel colors</div>
|
||||
<div>2. Pixel data hashed with SHA-256 ({(stats.dataSize / 1024).toFixed(1)}KB raw data)</div>
|
||||
<div>3. Mixed with timing entropy ({stats.captureTimeMicros}μs precision)</div>
|
||||
<div>4. Combined with {stats.interactionSamples} user interaction samples</div>
|
||||
<div>5. Enhanced with crypto.getRandomValues() (32 bytes)</div>
|
||||
<div>6. Final hash → {wordCount === 12 ? '128' : '256'} bits → {wordCount} BIP39 words</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">GENERATED SEED:</p>
|
||||
<div className="p-3 bg-[#0a0a0f] rounded-lg border border-[#39ff1450]">
|
||||
<p className="font-mono text-[10px] text-[#39ff14] blur-sm hover:blur-none transition-all cursor-pointer"
|
||||
title="Hover to reveal">
|
||||
{generatedMnemonic}
|
||||
</p>
|
||||
<p className="text-[9px] text-[#6ef3f7] mt-1">
|
||||
⚠️ Hover to reveal - Write this down securely
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#00f0ff30] space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Now send to parent
|
||||
onEntropyGenerated(generatedMnemonic, stats);
|
||||
}}
|
||||
className="w-full py-3 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-xl font-bold uppercase hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] transition-all"
|
||||
>
|
||||
Continue with this Seed
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
// Reset and try again
|
||||
setStep('permission');
|
||||
setStats(null);
|
||||
setGeneratedMnemonic('');
|
||||
setEntropy(0);
|
||||
setVariance(0);
|
||||
}}
|
||||
className="w-full py-2 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] rounded-lg text-sm font-bold hover:bg-[#00f0ff20] transition-all"
|
||||
>
|
||||
Retake Photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-[#1a1a2e] border-2 border-[#ff006e] rounded-lg">
|
||||
<p className="text-xs text-[#ff006e]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraEntropy;
|
||||
239
src/components/DiceEntropy.tsx
Normal file
239
src/components/DiceEntropy.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dices, CheckCircle2, AlertCircle, X } from 'lucide-react';
|
||||
import { InteractionEntropy } from '../lib/interactionEntropy';
|
||||
|
||||
interface DiceStats {
|
||||
rolls: string;
|
||||
length: number;
|
||||
distribution: number[];
|
||||
chiSquare: number;
|
||||
passed: boolean;
|
||||
interactionSamples: number;
|
||||
}
|
||||
|
||||
interface DiceEntropyProps {
|
||||
wordCount: 12 | 24;
|
||||
onEntropyGenerated: (mnemonic: string, stats: any) => void;
|
||||
onCancel: () => void;
|
||||
interactionEntropy: InteractionEntropy;
|
||||
}
|
||||
|
||||
const DiceEntropy: React.FC<DiceEntropyProps> = ({
|
||||
wordCount,
|
||||
onEntropyGenerated,
|
||||
onCancel,
|
||||
interactionEntropy
|
||||
}) => {
|
||||
const [rolls, setRolls] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [stats, setStats] = useState<DiceStats | null>(null);
|
||||
|
||||
const validateDiceRolls = (input: string): { valid: boolean; error: string } => {
|
||||
const clean = input.replace(/\s/g, '');
|
||||
|
||||
if (clean.length < 99) {
|
||||
return { valid: false, error: `Need at least 99 dice rolls (currently ${clean.length})` };
|
||||
}
|
||||
|
||||
if (/(\d)\1{6,}/.test(clean)) {
|
||||
return { valid: false, error: 'Too many repeated digits - roll again' };
|
||||
}
|
||||
|
||||
if (/(\d)(\d)\1\2\1\2\1\2/.test(clean)) {
|
||||
return { valid: false, error: 'Repeating pattern detected - roll again' };
|
||||
}
|
||||
|
||||
if (/(?:123456|654321)/.test(clean)) {
|
||||
return { valid: false, error: 'Sequential pattern detected - roll again' };
|
||||
}
|
||||
|
||||
const counts = Array(6).fill(0);
|
||||
for (const char of clean) {
|
||||
const digit = parseInt(char, 10);
|
||||
if (digit >= 1 && digit <= 6) counts[digit - 1]++;
|
||||
}
|
||||
|
||||
const expected = clean.length / 6;
|
||||
const threshold = expected * 0.4; // Allow 40% deviation
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (Math.abs(counts[i] - expected) > threshold) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Poor distribution: digit ${i + 1} appears ${counts[i]} times (expected ~${Math.round(expected)})`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const chiSquare = counts.reduce((sum, count) => {
|
||||
const diff = count - expected;
|
||||
return sum + (diff * diff) / expected;
|
||||
}, 0);
|
||||
|
||||
if (chiSquare > 15.5) { // p-value < 0.01 for 5 degrees of freedom
|
||||
return {
|
||||
valid: false,
|
||||
error: `Statistical test failed (χ²=${chiSquare.toFixed(2)}) - rolls too predictable`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, error: '' };
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const validation = validateDiceRolls(rolls);
|
||||
if (!validation.valid) {
|
||||
setError(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setProcessing(true);
|
||||
|
||||
const clean = rolls.replace(/\s/g, '');
|
||||
|
||||
// Calculate stats
|
||||
const counts = Array(6).fill(0);
|
||||
for (const char of clean) {
|
||||
const digit = parseInt(char);
|
||||
if (digit >= 1 && digit <= 6) counts[digit - 1]++;
|
||||
}
|
||||
|
||||
const expected = clean.length / 6;
|
||||
const chiSquare = counts.reduce((sum, count) => {
|
||||
const diff = count - expected;
|
||||
return sum + (diff * diff) / expected;
|
||||
}, 0);
|
||||
|
||||
const diceStats: DiceStats = {
|
||||
rolls: clean,
|
||||
length: clean.length,
|
||||
distribution: counts,
|
||||
chiSquare,
|
||||
passed: true,
|
||||
interactionSamples: interactionEntropy.getSampleCount().total,
|
||||
};
|
||||
|
||||
// Generate mnemonic
|
||||
const mnemonic = await generateMnemonicFromDice(clean);
|
||||
|
||||
// Show stats first
|
||||
setStats(diceStats);
|
||||
setProcessing(false);
|
||||
|
||||
// Then notify parent after a brief delay so user sees stats
|
||||
setTimeout(() => {
|
||||
onEntropyGenerated(mnemonic, diceStats);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const generateMnemonicFromDice = async (diceRolls: string): Promise<string> => {
|
||||
const interactionBytes = await interactionEntropy.getEntropyBytes();
|
||||
const cryptoBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
|
||||
const sources = [
|
||||
diceRolls,
|
||||
performance.now().toString(),
|
||||
Array.from(interactionBytes).join(','),
|
||||
Array.from(cryptoBytes).join(',')
|
||||
];
|
||||
|
||||
const combined = sources.join('|');
|
||||
const data = new TextEncoder().encode(combined);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
|
||||
const { entropyToMnemonic } = await import('bip39');
|
||||
const entropyLength = wordCount === 12 ? 16 : 32;
|
||||
const finalEntropy = new Uint8Array(hash).slice(0, entropyLength);
|
||||
|
||||
const entropyHex = Buffer.from(finalEntropy).toString('hex');
|
||||
return entropyToMnemonic(entropyHex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!stats && (
|
||||
<>
|
||||
<div className="p-4 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-3">
|
||||
<div className="flex items-center gap-2"><Dices size={20} className="text-[#00f0ff]" /><h3 className="text-sm font-bold text-[#00f0ff] uppercase">Dice Roll Entropy</h3></div>
|
||||
<div className="space-y-2 text-xs text-[#6ef3f7]">
|
||||
<p className="font-bold text-[#00f0ff]">Instructions:</p>
|
||||
<ul className="list-disc list-inside space-y-1 pl-2">
|
||||
<li>Roll a 6-sided die at least 99 times</li>
|
||||
<li>Enter each result (1-6) in order</li>
|
||||
<li>No spaces needed (e.g., 163452...)</li>
|
||||
<li>Pattern validation enabled</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest">Enter Dice Rolls</label>
|
||||
<textarea value={rolls} onChange={(e) => { setRolls(e.target.value.replace(/[^1-6\s]/g, '')); setError(''); }} placeholder="99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-sm text-[#00f0ff] 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 resize-none" />
|
||||
<p className="text-[10px] text-[#6ef3f7]">Current: {rolls.replace(/\s/g, '').length} rolls {rolls.replace(/\s/g, '').length >= 99 && ' ✓'}</p>
|
||||
</div>
|
||||
{error && (<div className="flex items-start gap-2 p-3 bg-[#0a0a0f] border border-[#ff006e] rounded-lg"><AlertCircle size={16} className="text-[#ff006e] shrink-0 mt-0.5" /><p className="text-xs text-[#ff006e]">{error}</p></div>)}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleGenerate} disabled={processing || rolls.replace(/\s/g, '').length < 99} className="flex-1 py-2.5 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-lg font-bold uppercase disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all">{processing ? 'Processing...' : 'Generate Seed'}</button>
|
||||
<button onClick={onCancel} className="px-4 py-2.5 bg-[#16213e] border-2 border-[#ff006e] text-[#ff006e] rounded-lg font-bold text-sm hover:bg-[#ff006e]/20 transition-all"><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 bg-[#16213e] border border-[#00f0ff]/30 rounded-lg">
|
||||
<AlertCircle size={14} className="text-[#00f0ff] shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] text-[#6ef3f7]"><strong className="text-[#00f0ff]">Privacy:</strong> All processing happens locally. Dice rolls are mixed with browser entropy and never stored or transmitted.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="p-4 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_30px_rgba(57,255,20,0.3)] space-y-4">
|
||||
<div className="flex items-center gap-2 text-[#39ff14]"><CheckCircle2 size={24} /><h3 className="text-sm font-bold uppercase">Dice Entropy Analysis</h3></div>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div><p className="text-[#00f0ff] font-bold mb-1">Primary Source:</p><p className="text-[#6ef3f7]">Physical Dice Rolls</p></div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">ROLL STATISTICS:</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-[10px]">
|
||||
<div>Total rolls:</div><div className="text-[#39ff14]">{stats.length}</div>
|
||||
<div>Chi-square test:</div><div className="text-[#39ff14]">{stats.chiSquare.toFixed(2)} (pass < 15.5)</div>
|
||||
<div>Validation:</div><div className="text-[#39ff14]">{stats.passed ? '✅ Passed' : '❌ Failed'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">DISTRIBUTION:</p>
|
||||
<div className="space-y-2">
|
||||
{stats.distribution.map((count, i) => {
|
||||
const percent = (count / stats.length) * 100;
|
||||
const expected = 16.67;
|
||||
const deviation = Math.abs(percent - expected);
|
||||
const color = deviation < 5 ? '#39ff14' : deviation < 8 ? '#ffd700' : '#ff9500';
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex justify-between text-[10px] mb-1"><span>Die face {i + 1}:</span><span style={{ color }}>{count} ({percent.toFixed(1)}%)</span></div>
|
||||
<div className="w-full bg-[#0a0a0f] rounded-full h-1.5 overflow-hidden"><div className="h-full transition-all" style={{ width: `${percent}%`, backgroundColor: color }} /></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[9px] text-[#6ef3f7] mt-2">Expected: ~16.67% per face</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">MIXED WITH:</p>
|
||||
<div className="space-y-1 text-[#6ef3f7] text-[10px]">
|
||||
<div>- crypto.getRandomValues() ✓</div>
|
||||
<div>- performance.now() ✓</div>
|
||||
<div>- Interaction timing ({stats.interactionSamples} samples) ✓</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-[#00f0ff]/30">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[#00f0ff] font-bold">Total Entropy:</span>
|
||||
<span className="text-lg font-bold text-[#39ff14]">256 bits</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiceEntropy;
|
||||
@@ -8,7 +8,7 @@ interface FooterProps {
|
||||
|
||||
const Footer: React.FC<FooterProps> = ({ appVersion, buildHash, buildTimestamp }) => {
|
||||
return (
|
||||
<footer className="text-center text-xs text-slate-400 p-4">
|
||||
<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>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Shield, Lock } from 'lucide-react';
|
||||
import { Shield, RefreshCw } 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;
|
||||
@@ -25,13 +24,12 @@ interface HeaderProps {
|
||||
sessionItems: StorageItem[];
|
||||
events: ClipboardEvent[];
|
||||
onOpenClipboardModal: () => void;
|
||||
activeTab: 'backup' | 'restore';
|
||||
setActiveTab: (tab: 'backup' | 'restore') => void;
|
||||
encryptedMnemonicCache: any;
|
||||
handleLockAndClear: () => void;
|
||||
activeTab: 'create' | 'backup' | 'restore' | 'seedblender';
|
||||
onRequestTabChange: (tab: 'create' | 'backup' | 'restore' | 'seedblender') => void;
|
||||
appVersion: string;
|
||||
isLocked: boolean;
|
||||
onToggleLock: () => void;
|
||||
isNetworkBlocked: boolean;
|
||||
onToggleNetwork: () => void;
|
||||
onResetAll: () => void; // NEW
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
@@ -42,32 +40,45 @@ const Header: React.FC<HeaderProps> = ({
|
||||
events,
|
||||
onOpenClipboardModal,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
encryptedMnemonicCache,
|
||||
handleLockAndClear,
|
||||
onRequestTabChange,
|
||||
appVersion,
|
||||
isLocked,
|
||||
onToggleLock
|
||||
isNetworkBlocked,
|
||||
onToggleNetwork,
|
||||
onResetAll
|
||||
}) => {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-slate-900 border-b border-slate-800 backdrop-blur-sm">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<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 (LEFT) | Reset (RIGHT) */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left: Logo & Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-teal-500 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
<div 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-white">
|
||||
SeedPGP <span className="text-teal-400">v{appVersion}</span>
|
||||
<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-slate-400">OpenPGP-secured BIP39 backup</p>
|
||||
<p className="text-xs text-[#6ef3f7]">OpenPGP-secured BIP39 backup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Monitoring Badges */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{/* Reset button - top right */}
|
||||
<button
|
||||
onClick={onResetAll}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[#16213e] border border-[#ff006e] text-[#ff006e] rounded-lg font-medium hover:bg-[#ff006e20] transition-all"
|
||||
title="Reset all data"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
<span className="hidden sm:inline">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ROW 2: Badges (LEFT) | Action Buttons (RIGHT) */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-[#00f0ff20]">
|
||||
{/* Left: Monitoring Badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SecurityBadge onClick={onOpenSecurityModal} />
|
||||
<div onClick={onOpenStorageModal} className="cursor-pointer">
|
||||
<StorageBadge localItems={localItems} sessionItems={sessionItems} />
|
||||
@@ -75,45 +86,80 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<div onClick={onOpenClipboardModal} className="cursor-pointer">
|
||||
<ClipboardBadge events={events} onOpenClipboardModal={onOpenClipboardModal} />
|
||||
</div>
|
||||
<EditLockBadge isLocked={isLocked} onToggle={onToggleLock} />
|
||||
</div>
|
||||
|
||||
{/* Spacer - pushes right content to the right */}
|
||||
<div className="flex-1"></div>
|
||||
|
||||
{/* Right: Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
{encryptedMnemonicCache && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Network Block toggle */}
|
||||
<button
|
||||
onClick={handleLockAndClear}
|
||||
className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50 transition-colors"
|
||||
onClick={onToggleNetwork}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-lg font-medium transition-all whitespace-nowrap ${
|
||||
isNetworkBlocked
|
||||
? 'bg-[#16213e] border border-[#ff006e] text-[#ff006e] hover:bg-[#ff006e20]'
|
||||
: 'bg-[#16213e] border border-[#39ff14] text-[#39ff14] hover:bg-[#39ff1420]'
|
||||
}`}
|
||||
title={isNetworkBlocked ? 'Network BLOCKED' : 'Network ACTIVE'}
|
||||
>
|
||||
<Lock size={16} />
|
||||
<span>Lock/Clear</span>
|
||||
<span className="text-sm">{isNetworkBlocked ? '🚫' : '🌐'}</span>
|
||||
<span className="hidden sm:inline text-[10px]">
|
||||
{isNetworkBlocked ? 'Blocked' : 'Active'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ROW 3: Navigation Tabs */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg ${activeTab === 'backup' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
onClick={() => setActiveTab('backup')}
|
||||
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={`px-4 py-2 rounded-lg ${activeTab === 'restore' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
onClick={() => setActiveTab('restore')}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Stack monitoring badges */}
|
||||
<div className="md:hidden flex items-center gap-3 mt-3 pt-3 border-t border-slate-800">
|
||||
<SecurityBadge onClick={onOpenSecurityModal} />
|
||||
<div onClick={onOpenStorageModal} className="cursor-pointer">
|
||||
<StorageBadge localItems={localItems} sessionItems={sessionItems} />
|
||||
</div>
|
||||
<div onClick={onOpenClipboardModal} className="cursor-pointer">
|
||||
<ClipboardBadge events={events} onOpenClipboardModal={onOpenClipboardModal} />
|
||||
</div>
|
||||
<EditLockBadge isLocked={isLocked} onToggle={onToggleLock} />
|
||||
<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>
|
||||
|
||||
@@ -52,12 +52,12 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-slate-200 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">
|
||||
{Icon && <Icon size={14} />} {label}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
@@ -69,8 +69,7 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<textarea
|
||||
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 ${isDragging && !readOnly ? 'border-teal-500 bg-teal-50' : 'border-slate-200'} ${
|
||||
readOnly ? 'blur-sm select-none' : ''
|
||||
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}
|
||||
value={value}
|
||||
@@ -78,8 +77,8 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{isDragging && !readOnly && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-teal-50/90 rounded-xl border-2 border-dashed border-teal-500 pointer-events-none z-10">
|
||||
<div className="text-teal-600 font-bold flex flex-col items-center animate-bounce">
|
||||
<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-[#ff006e] font-bold flex flex-col items-center animate-bounce" style={{ textShadow: '0 0 10px rgba(255,0,110,0.5)' }}>
|
||||
<Upload size={24} />
|
||||
<span className="text-sm mt-2">Drop Key File Here</span>
|
||||
</div>
|
||||
|
||||
@@ -1,223 +1,173 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Camera, Upload, X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Camera, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
interface QRScannerProps {
|
||||
onScanSuccess: (scannedText: string) => void;
|
||||
onScanSuccess: (data: string | Uint8Array) => void;
|
||||
onClose: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
|
||||
const [scanMode, setScanMode] = useState<'camera' | 'file' | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
export default function QRScanner({ onScanSuccess, onClose, onError }: QRScannerProps) {
|
||||
const [internalError, setInternalError] = useState<string>('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const startCamera = async () => {
|
||||
setError('');
|
||||
setScanMode('camera');
|
||||
setScanning(true);
|
||||
useEffect(() => {
|
||||
let stream: MediaStream | null = null;
|
||||
let scanInterval: number | null = null;
|
||||
let isCancelled = false;
|
||||
|
||||
// Wait for DOM to render the #qr-reader div
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const stopScanning = () => {
|
||||
if (scanInterval) clearInterval(scanInterval);
|
||||
if (stream) stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
const startScanning = async () => {
|
||||
try {
|
||||
// Check if we're on HTTPS or localhost
|
||||
if (window.location.protocol !== 'https:' && !window.location.hostname.includes('localhost')) {
|
||||
throw new Error('Camera requires HTTPS or localhost. Use: bun run dev');
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' }
|
||||
});
|
||||
|
||||
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');
|
||||
html5QrCodeRef.current = html5QrCode;
|
||||
scanInterval = window.setInterval(() => {
|
||||
if (isCancelled || !video || video.paused || video.ended) return;
|
||||
if (!video.videoWidth || !video.videoHeight) return;
|
||||
|
||||
await html5QrCode.start(
|
||||
{ facingMode: 'environment' },
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
aspectRatio: 1.0,
|
||||
},
|
||||
(decodedText) => {
|
||||
if (decodedText.startsWith('SEEDPGP1:')) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: 'dontInvert'
|
||||
});
|
||||
|
||||
if (code) {
|
||||
isCancelled = true;
|
||||
stopScanning();
|
||||
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 {
|
||||
setError('QR code found, but not a valid SEEDPGP1 frame');
|
||||
// Text QR - use the text property
|
||||
onScanSuccess(code.data);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Ignore frequent scanning errors
|
||||
|
||||
setTimeout(() => onClose(), 1000);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
);
|
||||
} 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);
|
||||
setError(`Camera failed: ${err.message || 'Permission denied or not available'}`);
|
||||
setScanning(false);
|
||||
setScanMode(null);
|
||||
setHasPermission(false);
|
||||
|
||||
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 () => {
|
||||
if (html5QrCodeRef.current) {
|
||||
try {
|
||||
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();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
stopScanning();
|
||||
};
|
||||
}, [onScanSuccess, onClose, onError]);
|
||||
|
||||
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="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in zoom-in-95">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-4 text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<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)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-[#00f0ff] flex items-center gap-2" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
||||
<Camera size={20} />
|
||||
<h2 className="font-bold text-lg">Scan QR Code</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
Scan QR Code
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-2 hover:bg-[#1a1a2e] rounded-lg transition-colors border-2 border-[#00f0ff]/30">
|
||||
<X size={20} className="text-[#6ef3f7]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 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">
|
||||
{internalError && (
|
||||
<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">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<p>{error}</p>
|
||||
<span>{internalError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Display */}
|
||||
{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">
|
||||
<CheckCircle2 size={16} className="shrink-0 mt-0.5" />
|
||||
<p>QR code scanned successfully!</p>
|
||||
<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} />
|
||||
<span>QR Code detected!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode Selection */}
|
||||
{!scanMode && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={startCamera}
|
||||
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg"
|
||||
>
|
||||
<Camera size={20} />
|
||||
Use Camera
|
||||
</button>
|
||||
<div className="relative bg-black rounded-lg overflow-hidden border-2 border-[#00f0ff]/30">
|
||||
<video ref={videoRef} className="w-full h-64 object-cover" playsInline muted />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
|
||||
{!hasPermission && !internalError && (
|
||||
<p className="text-sm text-[#6ef3f7] mt-3 text-center">Requesting camera access...</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
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"
|
||||
onClick={onClose}
|
||||
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} />
|
||||
Upload Image
|
||||
Cancel
|
||||
</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-teal-600" />
|
||||
<div>
|
||||
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
|
||||
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
|
||||
</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-teal-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>
|
||||
);
|
||||
|
||||
@@ -3,16 +3,55 @@ import { Download } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface QrDisplayProps {
|
||||
value: string;
|
||||
value: string | Uint8Array;
|
||||
}
|
||||
|
||||
export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
const [dataUrl, setDataUrl] = useState<string>('');
|
||||
const [dataUrl, setDataUrl] = useState('');
|
||||
const [debugInfo, setDebugInfo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
QRCode.toDataURL(value, {
|
||||
errorCorrectionLevel: 'M',
|
||||
if (!value) {
|
||||
setDataUrl('');
|
||||
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',
|
||||
width: 512,
|
||||
margin: 4,
|
||||
@@ -20,22 +59,29 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
dark: '#000000',
|
||||
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]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Generate filename: SeedPGP_YYYY-MM-DD_HHMMSS.png
|
||||
const now = new Date();
|
||||
const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const time = now.toTimeString().split(' ')[0].replace(/:/g, ''); // HHMMSS
|
||||
const date = now.toISOString().split('T')[0];
|
||||
const time = now.toTimeString().split(' ')[0].replace(/:/g, '');
|
||||
const filename = `SeedPGP_${date}_${time}.png`;
|
||||
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
@@ -47,25 +93,27 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
if (!dataUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-200">
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt="SeedPGP QR Code"
|
||||
className="w-80 h-80"
|
||||
/>
|
||||
<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="bg-[#16213e] p-6 rounded-lg inline-block shadow-[0_0_20px_rgba(0,240,255,0.3)] border-2 border-[#00f0ff]/30">
|
||||
<img src={dataUrl} alt="QR Code" className="w-full h-auto" />
|
||||
</div>
|
||||
|
||||
{debugInfo && (
|
||||
<div className="text-xs text-[#6ef3f7] font-mono">
|
||||
{debugInfo}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-500 text-center max-w-sm">
|
||||
Downloads as: SeedPGP_2026-01-28_231645.png
|
||||
<p className="text-xs text-[#6ef3f7]">
|
||||
Downloads as: SeedPGP_{new Date().toISOString().split('T')[0]}_HHMMSS.png
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,26 +11,26 @@ const CSP_POLICY = `default-src 'self'; script-src 'self'; style-src 'self' 'uns
|
||||
|
||||
export function ReadOnly({ isReadOnly, onToggle, buildHash, appVersion }: ReadOnlyProps) {
|
||||
return (
|
||||
<div className="pt-3 border-t border-slate-300">
|
||||
<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-teal-600 focus:ring-2 focus:ring-teal-500 transition-all"
|
||||
className="rounded text-[#00f0ff] focus:ring-2 focus:ring-[#00f0ff] transition-all"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||
<span className="text-xs font-medium text-[#6ef3f7] group-hover:text-[#00f0ff] transition-colors">
|
||||
Read-only Mode
|
||||
</span>
|
||||
</label>
|
||||
{isReadOnly && (
|
||||
<div className="mt-4 p-3 bg-slate-800 text-slate-200 rounded-lg text-xs space-y-2 animate-in fade-in">
|
||||
<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-slate-400">Version:</span> {appVersion}</p>
|
||||
<p><span className="font-semibold text-slate-400">Build:</span> {buildHash}</p>
|
||||
<p className="pt-1 font-semibold text-slate-400">Content Security Policy:</p>
|
||||
<p className="text-sky-300 break-words">{CSP_POLICY}</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -39,8 +39,8 @@ export const SecurityWarnings: React.FC = () => {
|
||||
description="If hosted online: DNS, HTTPS, CDN, and browser can see usage patterns. Use offline/local for maximum security."
|
||||
/>
|
||||
|
||||
<div className="pt-3 border-t border-slate-600 text-xs text-slate-400">
|
||||
<strong className="text-slate-300">Recommendation:</strong>{' '}
|
||||
<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>
|
||||
@@ -59,8 +59,8 @@ const Warning = ({
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span className="text-lg flex-shrink-0">{icon}</span>
|
||||
<div>
|
||||
<div className="font-semibold text-slate-200 mb-1">{title}</div>
|
||||
<div className="text-slate-400 leading-relaxed">{description}</div>
|
||||
<div 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>
|
||||
);
|
||||
|
||||
404
src/components/SeedBlender.tsx
Normal file
404
src/components/SeedBlender.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* @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, 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]);
|
||||
|
||||
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 })}
|
||||
onFocus={(e) => e.target.classList.remove('blur-sensitive')}
|
||||
onBlur={(e) => entry.rawInput && e.target.classList.add('blur-sensitive')}
|
||||
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 text-[#00f0ff] 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)} ${
|
||||
entry.rawInput ? 'blur-sensitive' : ''
|
||||
}`}
|
||||
/>
|
||||
{/* 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-sm 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,10 +18,10 @@ const ClipboardBadge: React.FC<ClipboardBadgeProps> = ({ events, onOpenClipboard
|
||||
// Determine badge style based on clipboard count
|
||||
const badgeStyle =
|
||||
count === 0
|
||||
? "text-green-500 bg-green-500/10 border-green-500/20" // Safe
|
||||
? "text-[#39ff14] bg-[#39ff14]/10 border-[#39ff14]/20" // Safe
|
||||
: count < 5
|
||||
? "text-amber-500 bg-amber-500/10 border-amber-500/30 font-semibold" // Warning
|
||||
: "text-red-500 bg-red-500/10 border-red-500/30 font-bold animate-pulse"; // Danger
|
||||
? "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
|
||||
|
||||
@@ -12,8 +12,8 @@ const EditLockBadge: React.FC<EditLockBadgeProps> = ({ isLocked, onToggle }) =>
|
||||
onClick={onToggle}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all hover:scale-105 ${
|
||||
isLocked
|
||||
? 'text-amber-500 bg-amber-500/10 border-amber-500/30 font-semibold'
|
||||
: 'text-green-500 bg-green-500/10 border-green-500/30'
|
||||
? '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'}
|
||||
>
|
||||
|
||||
28
src/components/badges/NetworkBlockBadge.tsx
Normal file
28
src/components/badges/NetworkBlockBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Wifi, WifiOff } from 'lucide-react';
|
||||
|
||||
interface NetworkBlockBadgeProps {
|
||||
isBlocked: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const NetworkBlockBadge: React.FC<NetworkBlockBadgeProps> = ({ isBlocked, onToggle }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||
isBlocked
|
||||
? 'bg-[#ff006e20] border border-[#ff006e] text-[#ff006e] hover:bg-[#ff006e30]'
|
||||
: 'bg-[#39ff1420] border border-[#39ff14] text-[#39ff14] hover:bg-[#39ff1430]'
|
||||
}`}
|
||||
title={isBlocked ? 'Network is BLOCKED' : 'Network is ACTIVE'}
|
||||
>
|
||||
{isBlocked ? <WifiOff size={12} /> : <Wifi size={12} />}
|
||||
<span className="hidden sm:inline">
|
||||
{isBlocked ? 'Blocked' : 'Active'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkBlockBadge;
|
||||
@@ -2,6 +2,37 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Prevent iOS zoom on input focus by ensuring font-size >= 16px */
|
||||
input, textarea, select {
|
||||
font-size: 16px !important; /* iOS won't zoom if 16px or larger */
|
||||
}
|
||||
|
||||
/* For smaller text, use transform scale instead */
|
||||
.text-xs input,
|
||||
.text-xs textarea {
|
||||
font-size: 16px !important;
|
||||
transform: scale(0.75);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
@@ -15,3 +46,24 @@ code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Sensitive data blur protection */
|
||||
.blur-sensitive {
|
||||
filter: blur(6px);
|
||||
transition: filter 0.2s ease;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.blur-sensitive:hover,
|
||||
.blur-sensitive:focus {
|
||||
filter: blur(0);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Mobile: tap to reveal */
|
||||
@media (pointer: coarse) {
|
||||
.blur-sensitive:active {
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
73
src/lib/interactionEntropy.ts
Normal file
73
src/lib/interactionEntropy.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Collects entropy from user interactions (mouse, keyboard, touch)
|
||||
* Runs in background to enhance any entropy generation method
|
||||
*/
|
||||
export class InteractionEntropy {
|
||||
private samples: number[] = [];
|
||||
private lastEvent = 0;
|
||||
private startTime = performance.now();
|
||||
private sources = { mouse: 0, keyboard: 0, touch: 0 };
|
||||
|
||||
constructor() {
|
||||
this.initListeners();
|
||||
}
|
||||
|
||||
private initListeners() {
|
||||
const handleEvent = (e: MouseEvent | KeyboardEvent | TouchEvent) => {
|
||||
const now = performance.now();
|
||||
const delta = now - this.lastEvent;
|
||||
|
||||
if (delta > 0 && delta < 10000) { // Ignore huge gaps
|
||||
this.samples.push(delta);
|
||||
|
||||
if (e instanceof MouseEvent) {
|
||||
this.samples.push(e.clientX ^ e.clientY);
|
||||
this.sources.mouse++;
|
||||
} else if (e instanceof KeyboardEvent) {
|
||||
this.samples.push(e.key.codePointAt(0) ?? 0);
|
||||
this.sources.keyboard++;
|
||||
} else if (e instanceof TouchEvent && e.touches[0]) {
|
||||
this.samples.push(e.touches[0].clientX ^ e.touches[0].clientY);
|
||||
this.sources.touch++;
|
||||
}
|
||||
}
|
||||
this.lastEvent = now;
|
||||
|
||||
// Keep last 256 samples (128 pairs)
|
||||
if (this.samples.length > 256) {
|
||||
this.samples.splice(0, this.samples.length - 256);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleEvent);
|
||||
document.addEventListener('keydown', handleEvent);
|
||||
document.addEventListener('touchmove', handleEvent);
|
||||
}
|
||||
|
||||
async getEntropyBytes(): Promise<Uint8Array> {
|
||||
// Convert samples to entropy via SHA-256
|
||||
const data = new TextEncoder().encode(
|
||||
this.samples.join(',') + performance.now()
|
||||
);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(hash);
|
||||
}
|
||||
|
||||
getSampleCount(): { mouse: number; keyboard: number; touch: number; total: number } {
|
||||
return {
|
||||
...this.sources,
|
||||
total: this.sources.mouse + this.sources.keyboard + this.sources.touch
|
||||
};
|
||||
}
|
||||
|
||||
getCollectionTime(): number {
|
||||
return performance.now() - this.startTime;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.samples = [];
|
||||
this.lastEvent = 0;
|
||||
this.startTime = performance.now();
|
||||
this.sources = { mouse: 0, keyboard: 0, touch: 0 };
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
unwrap,
|
||||
KruxCipher
|
||||
} from './krux';
|
||||
import { getWalletFingerprint } from "./bip32";
|
||||
|
||||
describe('Krux KEF Implementation', () => {
|
||||
// Test basic hex conversion
|
||||
@@ -85,30 +86,28 @@ describe('Krux KEF Implementation', () => {
|
||||
test('encryptToKrux and decryptFromKrux roundtrip', async () => {
|
||||
const mnemonic = 'test test test test test test test test test test test junk';
|
||||
const passphrase = 'secure-passphrase';
|
||||
const label = 'Test Seed';
|
||||
const iterations = 10000;
|
||||
|
||||
|
||||
const encrypted = await encryptToKrux({
|
||||
mnemonic,
|
||||
passphrase,
|
||||
label,
|
||||
iterations,
|
||||
version: 20,
|
||||
});
|
||||
|
||||
expect(encrypted.kefHex).toMatch(/^[0-9A-F]+$/);
|
||||
expect(encrypted.label).toBe(label);
|
||||
expect(encrypted.iterations).toBe(iterations);
|
||||
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({
|
||||
kefHex: encrypted.kefHex,
|
||||
kefData: encrypted.kefBase43, // Use kefBase43 for decryption
|
||||
passphrase,
|
||||
});
|
||||
|
||||
expect(decrypted.mnemonic).toBe(mnemonic);
|
||||
expect(decrypted.label).toBe(label);
|
||||
expect(decrypted.iterations).toBe(iterations);
|
||||
expect(decrypted.label).toBe(expectedLabel);
|
||||
expect(decrypted.iterations).toBe(100000);
|
||||
expect(decrypted.version).toBe(20);
|
||||
});
|
||||
|
||||
@@ -121,29 +120,27 @@ describe('Krux KEF Implementation', () => {
|
||||
|
||||
test('decryptFromKrux requires passphrase', async () => {
|
||||
await expect(decryptFromKrux({
|
||||
kefHex: '123456',
|
||||
kefData: '123456',
|
||||
passphrase: '',
|
||||
})).rejects.toThrow('Passphrase is required');
|
||||
})).rejects.toThrow('Invalid Krux data: Not a valid Hex or Base43 string.'); // Updated error message
|
||||
});
|
||||
|
||||
test('wrong passphrase fails decryption', async () => {
|
||||
const mnemonic = 'test mnemonic';
|
||||
const passphrase = 'correct-passphrase';
|
||||
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const correctPassphrase = 'correct';
|
||||
const wrongPassphrase = 'wrong';
|
||||
|
||||
const encrypted = await encryptToKrux({
|
||||
mnemonic,
|
||||
passphrase,
|
||||
});
|
||||
const { kefBase43 } = await encryptToKrux({ mnemonic, passphrase: correctPassphrase });
|
||||
|
||||
await expect(decryptFromKrux({
|
||||
kefHex: encrypted.kefHex,
|
||||
passphrase: 'wrong-passphrase',
|
||||
})).rejects.toThrow(/Krux decryption failed/);
|
||||
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', 'salt', 10000);
|
||||
const cipher = new KruxCipher('passphrase', new TextEncoder().encode('salt'), 10000);
|
||||
const plaintext = new TextEncoder().encode('secret message');
|
||||
|
||||
const encrypted = await cipher.encrypt(plaintext);
|
||||
@@ -153,15 +150,15 @@ describe('Krux KEF Implementation', () => {
|
||||
});
|
||||
|
||||
test('KruxCipher rejects unsupported version', async () => {
|
||||
const cipher = new KruxCipher('passphrase', 'salt', 10000);
|
||||
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');
|
||||
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', 'salt', 10000);
|
||||
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)
|
||||
|
||||
@@ -169,21 +166,28 @@ describe('Krux KEF Implementation', () => {
|
||||
});
|
||||
|
||||
test('iterations scaling works correctly', () => {
|
||||
// Test that iterations are scaled properly when divisible by 10000
|
||||
const label = 'Test';
|
||||
const version = 20;
|
||||
const payload = new Uint8Array([1, 2, 3]);
|
||||
const payload = new TextEncoder().encode('test payload');
|
||||
|
||||
// 200000 should be scaled to 20 in the envelope
|
||||
const wrapped1 = wrap(label, version, 200000, payload);
|
||||
expect(wrapped1[6]).toBe(0); // 200000 / 10000 = 20
|
||||
expect(wrapped1[6]).toBe(0);
|
||||
expect(wrapped1[7]).toBe(0);
|
||||
expect(wrapped1[8]).toBe(20);
|
||||
|
||||
// 10001 should not be scaled
|
||||
const wrapped2 = wrap(label, version, 10001, payload);
|
||||
const iterStart = 2 + label.length;
|
||||
const iters = (wrapped2[iterStart] << 16) | (wrapped2[iterStart + 1] << 8) | wrapped2[iterStart + 2];
|
||||
expect(iters).toBe(10001);
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
387
src/lib/krux.ts
387
src/lib/krux.ts
@@ -1,137 +1,64 @@
|
||||
// src/lib/krux.ts
|
||||
// Krux KEF (Krux Encryption Format) implementation
|
||||
// Compatible with Krux firmware (AES-GCM, label as salt, hex QR)
|
||||
// Currently implements version 20 (AES-GCM without compression)
|
||||
// Version 21 (AES-GCM +c) support can be added later with compression
|
||||
|
||||
// KEF version definitions (matches Python reference)
|
||||
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; // GCM tag length (4 for version 20, full 16 for v1?)
|
||||
compress: boolean;
|
||||
auth: number;
|
||||
}> = {
|
||||
20: { name: "AES-GCM", auth: 4 },
|
||||
// Version 21 would be: { name: "AES-GCM +c", compress: true, auth: 4 }
|
||||
// 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 },
|
||||
};
|
||||
|
||||
// IV length for GCM mode
|
||||
const GCM_IV_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* Convert data to a proper ArrayBuffer for Web Crypto API.
|
||||
* Ensures it's not a SharedArrayBuffer.
|
||||
*/
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
// Always create a new ArrayBuffer and copy the data
|
||||
const buffer = new ArrayBuffer(data.length);
|
||||
// Create a new ArrayBuffer and copy the contents
|
||||
const buffer = new ArrayBuffer(data.byteLength);
|
||||
new Uint8Array(buffer).set(data);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wrap data into KEF envelope format (matches Python exactly)
|
||||
* Format: [1 byte label length][label bytes][1 byte version][3 bytes iterations][payload]
|
||||
*/
|
||||
export function wrap(label: string, version: number, iterations: number, payload: Uint8Array): Uint8Array {
|
||||
const labelBytes = new TextEncoder().encode(label);
|
||||
if (!(0 <= labelBytes.length && labelBytes.length <= 252)) {
|
||||
throw new Error("Label too long (max 252 bytes)");
|
||||
}
|
||||
|
||||
const lenId = new Uint8Array([labelBytes.length]);
|
||||
const versionByte = new Uint8Array([version]);
|
||||
|
||||
let itersBytes = new Uint8Array(3);
|
||||
// Krux firmware expects iterations in multiples of 10000 when possible
|
||||
if (iterations % 10000 === 0) {
|
||||
const scaled = iterations / 10000;
|
||||
if (!(1 <= scaled && scaled <= 10000)) {
|
||||
throw new Error("Iterations out of scaled range");
|
||||
}
|
||||
itersBytes[0] = (scaled >> 16) & 0xff;
|
||||
itersBytes[1] = (scaled >> 8) & 0xff;
|
||||
itersBytes[2] = scaled & 0xff;
|
||||
} else {
|
||||
if (!(10000 < iterations && iterations < 2**24)) {
|
||||
throw new Error("Iterations out of range");
|
||||
}
|
||||
itersBytes[0] = (iterations >> 16) & 0xff;
|
||||
itersBytes[1] = (iterations >> 8) & 0xff;
|
||||
itersBytes[2] = iterations & 0xff;
|
||||
}
|
||||
|
||||
return new Uint8Array([...lenId, ...labelBytes, ...versionByte, ...itersBytes, ...payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap KEF envelope to extract components (matches Python exactly)
|
||||
*/
|
||||
export function unwrap(envelope: Uint8Array): {
|
||||
label: string;
|
||||
version: number;
|
||||
iterations: number;
|
||||
payload: Uint8Array
|
||||
} {
|
||||
if (envelope.length < 5) {
|
||||
throw new Error("Invalid KEF envelope: too short");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
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, version, iterations, payload };
|
||||
return { label, labelBytes, version, iterations, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Krux Cipher class for AES-GCM encryption/decryption
|
||||
*/
|
||||
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: string, iterations: number) {
|
||||
const encoder = new TextEncoder();
|
||||
constructor(passphrase: string, salt: Uint8Array, iterations: number) {
|
||||
this.keyPromise = (async () => {
|
||||
// Import passphrase as raw key material
|
||||
const passphraseBytes = encoder.encode(passphrase);
|
||||
const passphraseBuffer = toArrayBuffer(passphraseBytes);
|
||||
// Use pure-JS PBKDF2 implementation which has been validated against Krux's test vector
|
||||
const derivedKeyBytes = await pbkdf2HmacSha256(passphrase, salt, iterations, 32);
|
||||
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
// Import the derived bytes as an AES-GCM key
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
passphraseBuffer,
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
|
||||
// Derive AES-GCM key using PBKDF2
|
||||
const saltBytes = encoder.encode(salt);
|
||||
const saltBuffer = toArrayBuffer(saltBytes);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: saltBuffer,
|
||||
iterations: Math.max(1, iterations),
|
||||
hash: "SHA-256"
|
||||
},
|
||||
baseKey,
|
||||
toArrayBuffer(derivedKeyBytes),
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
@@ -139,55 +66,28 @@ export class KruxCipher {
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext using AES-GCM
|
||||
*/
|
||||
// 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}`);
|
||||
if (!v) throw new Error(`Unsupported KEF version: ${version}`);
|
||||
|
||||
let dataToEncrypt = plaintext;
|
||||
if (v.compress) {
|
||||
dataToEncrypt = pako.deflate(plaintext);
|
||||
}
|
||||
|
||||
// Note: No compression for version 20
|
||||
// For version 21, we would add compression here
|
||||
|
||||
// Ensure ivBytes is a fresh Uint8Array with its own ArrayBuffer (not SharedArrayBuffer)
|
||||
let ivBytes: Uint8Array;
|
||||
if (iv) {
|
||||
// Copy the iv to ensure we have our own buffer
|
||||
ivBytes = new Uint8Array(iv.length);
|
||||
ivBytes.set(iv);
|
||||
} else {
|
||||
// Create new random IV with a proper ArrayBuffer
|
||||
ivBytes = new Uint8Array(GCM_IV_LENGTH);
|
||||
crypto.getRandomValues(ivBytes);
|
||||
}
|
||||
let ivBytes = iv ? new Uint8Array(iv) : crypto.getRandomValues(new Uint8Array(GCM_IV_LENGTH));
|
||||
|
||||
const key = await this.keyPromise;
|
||||
const plaintextBuffer = toArrayBuffer(plaintext);
|
||||
const plaintextBuffer = toArrayBuffer(dataToEncrypt);
|
||||
const ivBuffer = toArrayBuffer(ivBytes);
|
||||
|
||||
// Use auth length from version definition (in bytes, convert to bits)
|
||||
const tagLengthBits = v.auth * 8;
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: ivBuffer,
|
||||
tagLength: tagLengthBits
|
||||
},
|
||||
key,
|
||||
plaintextBuffer
|
||||
);
|
||||
|
||||
// For GCM, encrypted result includes ciphertext + tag
|
||||
// Separate ciphertext and tag
|
||||
const authBytes = v.auth;
|
||||
const 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 - authBytes);
|
||||
const tag = encryptedBytes.slice(encryptedBytes.length - authBytes);
|
||||
const ciphertext = encryptedBytes.slice(0, encryptedBytes.length - v.auth);
|
||||
const tag = encryptedBytes.slice(encryptedBytes.length - v.auth);
|
||||
|
||||
// Combine IV + ciphertext + tag (matches Python format)
|
||||
const combined = new Uint8Array(ivBytes.length + ciphertext.length + tag.length);
|
||||
combined.set(ivBytes, 0);
|
||||
combined.set(ciphertext, ivBytes.length);
|
||||
@@ -195,73 +95,43 @@ export class KruxCipher {
|
||||
return combined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt payload using AES-GCM
|
||||
*/
|
||||
async decrypt(payload: Uint8Array, version: number): Promise<Uint8Array> {
|
||||
const v = VERSIONS[version];
|
||||
if (!v) {
|
||||
throw new Error(`Unsupported KEF version: ${version}`);
|
||||
}
|
||||
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 ivLen = GCM_IV_LENGTH;
|
||||
const authBytes = v.auth;
|
||||
|
||||
// Payload is IV + ciphertext + tag
|
||||
if (payload.length < ivLen + authBytes) {
|
||||
throw new Error("Payload too short for AES-GCM");
|
||||
}
|
||||
|
||||
// Extract IV, ciphertext, and tag
|
||||
const iv = payload.slice(0, ivLen);
|
||||
const ciphertext = payload.slice(ivLen, payload.length - authBytes);
|
||||
const tag = payload.slice(payload.length - authBytes);
|
||||
const 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 {
|
||||
// For Web Crypto, we need to combine ciphertext + tag
|
||||
const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
|
||||
ciphertextWithTag.set(ciphertext, 0);
|
||||
ciphertextWithTag.set(tag, ciphertext.length);
|
||||
|
||||
const ciphertextBuffer = toArrayBuffer(ciphertextWithTag);
|
||||
const ivBuffer = toArrayBuffer(iv);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: ivBuffer,
|
||||
tagLength: authBytes * 8
|
||||
},
|
||||
key,
|
||||
ciphertextBuffer
|
||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: toArrayBuffer(iv), tagLength: v.auth * 8 }, key, toArrayBuffer(ciphertextWithTag)
|
||||
);
|
||||
|
||||
return new Uint8Array(decrypted);
|
||||
let decrypted = new Uint8Array(decryptedBuffer);
|
||||
|
||||
if (v.compress) {
|
||||
decrypted = pako.inflate(decrypted);
|
||||
}
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
// Web Crypto throws generic errors for decryption failure
|
||||
// Convert to user-friendly message
|
||||
console.error("Krux decryption internal error:", error);
|
||||
throw new Error("Krux decryption failed - wrong passphrase or corrupted data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to bytes
|
||||
*/
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
// Remove any whitespace and optional KEF: prefix
|
||||
const cleaned = hex.trim().replace(/\s/g, '').replace(/^KEF:/i, '');
|
||||
|
||||
if (!/^[0-9a-fA-F]+$/.test(cleaned)) {
|
||||
throw new Error("Invalid hex string");
|
||||
}
|
||||
|
||||
if (cleaned.length % 2 !== 0) {
|
||||
throw new Error("Hex string must have even length");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -269,63 +139,110 @@ export function hexToBytes(hex: string): Uint8Array {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to hex string
|
||||
*/
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt mnemonic to KEF format
|
||||
*/
|
||||
export async function encryptToKrux(params: {
|
||||
mnemonic: string;
|
||||
passphrase: string;
|
||||
label?: string;
|
||||
iterations?: number;
|
||||
version?: number;
|
||||
}): Promise<{ kefHex: string; label: string; version: number; iterations: number }> {
|
||||
const label = params.label || "Seed Backup";
|
||||
const iterations = params.iterations || 200000;
|
||||
const version = params.version || 20;
|
||||
|
||||
if (!params.passphrase) {
|
||||
throw new Error("Passphrase is required for Krux encryption");
|
||||
// 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.");
|
||||
}
|
||||
|
||||
const mnemonicBytes = new TextEncoder().encode(params.mnemonic);
|
||||
const cipher = new KruxCipher(params.passphrase, label, iterations);
|
||||
const payload = await cipher.encrypt(mnemonicBytes, version);
|
||||
const kef = wrap(label, version, iterations, payload);
|
||||
|
||||
return {
|
||||
kefHex: bytesToHex(kef),
|
||||
label,
|
||||
version,
|
||||
iterations
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt KEF hex to mnemonic
|
||||
*/
|
||||
export async function decryptFromKrux(params: {
|
||||
kefHex: string;
|
||||
passphrase: string;
|
||||
}): Promise<{ mnemonic: string; label: string; version: number; iterations: number }> {
|
||||
if (!params.passphrase) {
|
||||
// STEP 3: Check passphrase (only after data structure is validated)
|
||||
if (!passphrase) {
|
||||
throw new Error("Passphrase is required for Krux decryption");
|
||||
}
|
||||
|
||||
const bytes = hexToBytes(params.kefHex);
|
||||
const { label, version, iterations, payload } = unwrap(bytes);
|
||||
const cipher = new KruxCipher(params.passphrase, label, iterations);
|
||||
// STEP 4: Decrypt
|
||||
const cipher = new KruxCipher(passphrase, labelBytes, iterations);
|
||||
const decrypted = await cipher.decrypt(payload, version);
|
||||
const mnemonic = await entropyToMnemonic(decrypted);
|
||||
|
||||
const mnemonic = new TextDecoder().decode(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,8 @@
|
||||
import * as openpgp from "openpgp";
|
||||
import { base45Encode, base45Decode } from "./base45";
|
||||
import { crc16CcittFalse } from "./crc16";
|
||||
import { encryptToKrux, decryptFromKrux, hexToBytes } from "./krux";
|
||||
import { encryptToKrux, decryptFromKrux } from "./krux";
|
||||
import { decodeSeedQR } from './seedqr';
|
||||
import type {
|
||||
SeedPgpPlaintext,
|
||||
ParsedSeedPgpFrame,
|
||||
@@ -53,13 +54,11 @@ export function frameEncode(pgpBinary: Uint8Array): string {
|
||||
|
||||
export function frameParse(text: string): ParsedSeedPgpFrame {
|
||||
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(":");
|
||||
if (parts.length < 4) {
|
||||
throw new Error("Invalid frame format (need at least 4 colon-separated parts)");
|
||||
}
|
||||
|
||||
const prefix = parts[0];
|
||||
const frame = parts[1];
|
||||
const crc16 = parts[2].toUpperCase();
|
||||
@@ -70,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)");
|
||||
|
||||
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 {
|
||||
const f = frameParse(frameText);
|
||||
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);
|
||||
if (crc !== f.crc16) {
|
||||
throw new Error(`CRC16 mismatch! Expected: ${f.crc16}, Got: ${crc}. QR scan may be corrupted.`);
|
||||
@@ -222,14 +232,11 @@ export async function encryptToSeed(params: EncryptionParams): Promise<Encryptio
|
||||
try {
|
||||
const result = await encryptToKrux({
|
||||
mnemonic: plaintextStr,
|
||||
passphrase,
|
||||
label: params.kruxLabel,
|
||||
iterations: params.kruxIterations,
|
||||
version: params.kruxVersion,
|
||||
passphrase: passphrase
|
||||
});
|
||||
|
||||
return {
|
||||
framed: result.kefHex,
|
||||
framed: result.kefBase43,
|
||||
label: result.label,
|
||||
version: result.version,
|
||||
iterations: result.iterations,
|
||||
@@ -274,7 +281,7 @@ export async function decryptFromSeed(params: DecryptionParams): Promise<SeedPgp
|
||||
|
||||
try {
|
||||
const result = await decryptFromKrux({
|
||||
kefHex: params.frameText,
|
||||
kefData: params.frameText,
|
||||
passphrase,
|
||||
});
|
||||
|
||||
@@ -294,6 +301,25 @@ export async function decryptFromSeed(params: DecryptionParams): Promise<SeedPgp
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -306,31 +332,44 @@ export async function decryptFromSeed(params: DecryptionParams): Promise<SeedPgp
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Check for SEEDPGP1 format
|
||||
// 1. Definite PGP
|
||||
if (trimmed.startsWith('SEEDPGP1:')) {
|
||||
return 'pgp';
|
||||
}
|
||||
|
||||
// Check for hex format (Krux KEF)
|
||||
const cleaned = trimmed.replace(/\s/g, '').replace(/^KEF:/i, '');
|
||||
if (/^[0-9a-fA-F]+$/.test(cleaned) && cleaned.length % 2 === 0) {
|
||||
// Try to parse as KEF to confirm
|
||||
try {
|
||||
const bytes = hexToBytes(cleaned);
|
||||
if (bytes.length >= 5) {
|
||||
const lenId = bytes[0];
|
||||
if (lenId > 0 && lenId <= 252 && 1 + lenId + 4 <= bytes.length) {
|
||||
return 'krux';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid KEF, fall through
|
||||
// 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';
|
||||
}
|
||||
|
||||
// Default to PGP for backward compatibility
|
||||
return 'pgp';
|
||||
// 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);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type ParsedSeedPgpFrame = {
|
||||
kind: "single";
|
||||
crc16: string;
|
||||
b45: string;
|
||||
rawPayload?: boolean;
|
||||
};
|
||||
|
||||
// Krux KEF types
|
||||
@@ -20,7 +21,7 @@ export type KruxEncryptionParams = {
|
||||
version?: number;
|
||||
};
|
||||
|
||||
export type EncryptionMode = 'pgp' | 'krux';
|
||||
export type EncryptionMode = 'pgp' | 'krux' | 'seedqr' | 'text';
|
||||
|
||||
export type EncryptionParams = {
|
||||
plaintext: SeedPgpPlaintext | string;
|
||||
@@ -41,8 +42,9 @@ export type DecryptionParams = {
|
||||
};
|
||||
|
||||
export type EncryptionResult = {
|
||||
framed: string;
|
||||
framed: string | Uint8Array;
|
||||
pgpBytes?: Uint8Array;
|
||||
kefBytes?: Uint8Array; // Added for Krux binary output
|
||||
recipientFingerprint?: string;
|
||||
label?: string;
|
||||
version?: number;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './polyfills';
|
||||
|
||||
// Suppress OpenPGP.js AES cipher warnings
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
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'
|
||||
|
||||
@@ -12,6 +15,9 @@ const gitHash = execSync('git rev-parse --short HEAD').toString().trim()
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
basicSsl(),
|
||||
react(),
|
||||
{
|
||||
name: 'html-transform',
|
||||
@@ -20,6 +26,24 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
],
|
||||
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: {
|
||||
@@ -30,5 +54,6 @@ export default defineConfig({
|
||||
'__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