mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb5184b8a | ||
|
|
422fe04a12 | ||
|
|
ebeea79a33 | ||
|
|
faf58dc49d | ||
|
|
46982794cc | ||
|
|
9ffdbbd50f | ||
|
|
b024856c08 | ||
|
|
a919e8bf09 | ||
|
|
e4516f3d19 | ||
|
|
4b5bd80be6 | ||
|
|
8124375537 | ||
|
|
2107dab501 | ||
|
|
0f397859e6 | ||
|
|
d4919f3d93 | ||
|
|
c1b1f566df | ||
|
|
6bbfe665cd | ||
|
|
8e656749fe | ||
|
|
5a4018dcfe | ||
|
|
94764248d0 | ||
|
|
a4a737540a | ||
|
|
b7da81c5d4 | ||
|
|
c55390228b | ||
|
|
3285bc383e | ||
|
|
307a97516a | ||
|
|
b1d3406cc7 | ||
| a96b39bb62 | |||
| 7d49f9cffd | |||
|
|
24f50014af | ||
|
|
b2be6d381c | ||
| 34633af539 | |||
|
|
2959b44e39 |
291
DEVELOPMENT.md
Normal file
291
DEVELOPMENT.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
Here's your `DEVELOPMENT.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Development Guide - SeedPGP v1.1.0
|
||||||
|
|
||||||
|
## Architecture Quick Reference
|
||||||
|
|
||||||
|
### Core Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/types.ts
|
||||||
|
interface SeedPgpPlaintext {
|
||||||
|
v: number; // Version (always 1)
|
||||||
|
t: string; // Type ("bip39")
|
||||||
|
w: string; // Mnemonic words (normalized)
|
||||||
|
l: string; // Language ("en")
|
||||||
|
pp: number; // BIP39 passphrase used? (0 or 1)
|
||||||
|
fpr?: string[]; // Optional recipient fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedSeedPgpFrame {
|
||||||
|
kind: "single"; // Frame type
|
||||||
|
crc16: string; // 4-digit hex checksum
|
||||||
|
b45: string; // Base45 payload
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frame Format
|
||||||
|
|
||||||
|
```
|
||||||
|
SEEDPGP1:0:ABCD:BASE45DATA
|
||||||
|
|
||||||
|
SEEDPGP1 - Protocol identifier + version
|
||||||
|
0 - Frame number (single frame)
|
||||||
|
ABCD - CRC16-CCITT-FALSE checksum (4 hex digits)
|
||||||
|
BASE45 - Base45-encoded PGP binary message
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Functions
|
||||||
|
|
||||||
|
#### Encryption Flow
|
||||||
|
```typescript
|
||||||
|
buildPlaintext(mnemonic, bip39PassphraseUsed, recipientFingerprints?)
|
||||||
|
→ SeedPgpPlaintext
|
||||||
|
|
||||||
|
encryptToSeedPgp({ plaintext, publicKeyArmored?, messagePassword? })
|
||||||
|
→ { framed: string, pgpBytes: Uint8Array, recipientFingerprint?: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Decryption Flow
|
||||||
|
```typescript
|
||||||
|
decryptSeedPgp({ frameText, privateKeyArmored?, privateKeyPassphrase?, messagePassword? })
|
||||||
|
→ SeedPgpPlaintext
|
||||||
|
|
||||||
|
frameDecodeToPgpBytes(frameText)
|
||||||
|
→ Uint8Array (with CRC16 validation)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Encoding/Decoding
|
||||||
|
```typescript
|
||||||
|
frameEncode(pgpBinary: Uint8Array) → "SEEDPGP1:0:CRC16:BASE45"
|
||||||
|
frameParse(text: string) → ParsedSeedPgpFrame
|
||||||
|
frameDecodeToPgpBytes(frameText: string) → Uint8Array
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"openpgp": "^6.3.0", // PGP encryption (curve25519Legacy)
|
||||||
|
"bun-types": "latest", // Bun runtime types
|
||||||
|
"react": "^18.x", // UI framework
|
||||||
|
"vite": "^5.x" // Build tool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenPGP.js v6 Quirks
|
||||||
|
|
||||||
|
⚠️ **Important compatibility notes:**
|
||||||
|
|
||||||
|
1. **Empty password array bug**: Never pass `passwords: []` to `decrypt()`. Only include if non-empty:
|
||||||
|
```typescript
|
||||||
|
if (msgPw) {
|
||||||
|
decryptOptions.passwords = [msgPw];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Curve naming**: Use `curve25519Legacy` (not `curve25519`) in `generateKey()`
|
||||||
|
|
||||||
|
3. **Key validation**: Always call `getEncryptionKey()` to verify public key has usable subkeys
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
seedpgp-web/
|
||||||
|
├── src/
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── seedpgp.ts # Core encrypt/decrypt logic
|
||||||
|
│ │ ├── seedpgp.test.ts # Test vectors (15 tests)
|
||||||
|
│ │ ├── base45.ts # Base45 encoder/decoder
|
||||||
|
│ │ ├── crc16.ts # CRC16-CCITT-FALSE
|
||||||
|
│ │ └── types.ts # TypeScript interfaces
|
||||||
|
│ ├── App.tsx # React UI entry
|
||||||
|
│ └── main.tsx # Vite bootstrap
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── vite.config.ts
|
||||||
|
├── README.md
|
||||||
|
└── DEVELOPMENT.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All tests
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
bun test --watch
|
||||||
|
|
||||||
|
# Verbose output
|
||||||
|
bun test --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev # Start Vite dev server
|
||||||
|
bun run build # Production build
|
||||||
|
bun run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Features
|
||||||
|
|
||||||
|
1. **Write tests first** in `seedpgp.test.ts`
|
||||||
|
2. **Implement in** `src/lib/seedpgp.ts`
|
||||||
|
3. **Update types** in `types.ts` if needed
|
||||||
|
4. **Run full test suite**: `bun test`
|
||||||
|
5. **Commit with conventional commits**: `feat: add QR generation`
|
||||||
|
|
||||||
|
## Feature Agenda
|
||||||
|
|
||||||
|
### 🚧 v1.2.0 - QR Code Round-Trip
|
||||||
|
|
||||||
|
**Goal**: Read back QR code and decrypt with user-provided credentials
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Add QR code generation from `encrypted.framed`
|
||||||
|
- Library: `qrcode` or `qr-code-styling`
|
||||||
|
- Input: SEEDPGP1 frame string
|
||||||
|
- Output: QR code image/canvas/SVG
|
||||||
|
|
||||||
|
- [ ] Add QR code scanner UI
|
||||||
|
- Library: `html5-qrcode` or `jsqr`
|
||||||
|
- Camera/file upload input
|
||||||
|
- Parse scanned text → `frameText`
|
||||||
|
|
||||||
|
- [ ] Build decrypt UI form
|
||||||
|
- Input fields:
|
||||||
|
- Scanned QR text (auto-filled)
|
||||||
|
- Private key (file upload or paste)
|
||||||
|
- Key passphrase (password input)
|
||||||
|
- OR message password (alternative)
|
||||||
|
- Call `decryptSeedPgp()`
|
||||||
|
- Display recovered mnemonic + metadata
|
||||||
|
|
||||||
|
- [ ] Add visual feedback
|
||||||
|
- CRC16 validation status
|
||||||
|
- Key fingerprint match indicator
|
||||||
|
- Decryption success/error states
|
||||||
|
|
||||||
|
**API Usage**:
|
||||||
|
```typescript
|
||||||
|
// Generate QR
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
const { framed } = await encryptToSeedPgp({ ... });
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(framed);
|
||||||
|
|
||||||
|
// Scan and decrypt
|
||||||
|
const scannedText = "SEEDPGP1:0:ABCD:..."; // from scanner
|
||||||
|
const decrypted = await decryptSeedPgp({
|
||||||
|
frameText: scannedText,
|
||||||
|
privateKeyArmored: userKey,
|
||||||
|
privateKeyPassphrase: userPassword,
|
||||||
|
});
|
||||||
|
console.log(decrypted.w); // Recovered mnemonic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Notes**:
|
||||||
|
- Never log decrypted mnemonics in production
|
||||||
|
- Clear sensitive data from memory after use
|
||||||
|
- Validate CRC16 before attempting decrypt
|
||||||
|
- Show key fingerprint for user verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔮 Future Ideas (v1.3+)
|
||||||
|
|
||||||
|
- [ ] Multi-frame support (for larger payloads)
|
||||||
|
- [ ] Password-only (SKESK) encryption flow
|
||||||
|
- [ ] Shamir Secret Sharing integration
|
||||||
|
- [ ] Hardware wallet key generation
|
||||||
|
- [ ] Mobile companion app (React Native)
|
||||||
|
- [ ] Printable paper backup templates
|
||||||
|
- [ ] Encrypted cloud backup with PBKDF2
|
||||||
|
- [ ] BIP85 child mnemonic derivation
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
### Enable verbose PGP logging
|
||||||
|
|
||||||
|
Uncomment in `seedpgp.ts`:
|
||||||
|
```typescript
|
||||||
|
console.log("Raw PGP hex:", Array.from(pgpBytes).map(...));
|
||||||
|
console.log("SeedPGP: message packets:", ...);
|
||||||
|
console.log("SeedPGP: encryption key IDs:", ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test with known vectors
|
||||||
|
|
||||||
|
Use Trezor vectors from test file:
|
||||||
|
```bash
|
||||||
|
bun test "Trezor" # Run only Trezor tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validate frame manually
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { frameParse } from "./lib/seedpgp";
|
||||||
|
const parsed = frameParse("SEEDPGP1:0:ABCD:...");
|
||||||
|
console.log(parsed); // Check structure
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- **Functions**: Async by default, explicit return types
|
||||||
|
- **Errors**: Throw descriptive Error objects with context
|
||||||
|
- **Naming**: `camelCase` for functions, `PascalCase` for types
|
||||||
|
- **Comments**: Only for non-obvious crypto/encoding logic
|
||||||
|
- **Testing**: One test per edge case, descriptive names
|
||||||
|
|
||||||
|
## Git Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Feature branch
|
||||||
|
git checkout -b feat/qr-generation
|
||||||
|
|
||||||
|
# Conventional commits
|
||||||
|
git commit -m "feat(qr): add QR code generation"
|
||||||
|
git commit -m "test(qr): add QR round-trip test"
|
||||||
|
|
||||||
|
# Tag releases
|
||||||
|
git tag -a v1.2.0 -m "Release v1.2.0 - QR round-trip"
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
## Questions for Next Session
|
||||||
|
|
||||||
|
When continuing development, provide:
|
||||||
|
|
||||||
|
1. **Feature context**: "Adding QR code generation for v1.2.0"
|
||||||
|
2. **Current code**: Paste relevant files you're modifying
|
||||||
|
3. **Specific question**: "How should I structure the QR scanner component?"
|
||||||
|
|
||||||
|
Example starter prompt:
|
||||||
|
```
|
||||||
|
I'm working on seedpgp-web v1.1.0 (BIP39 PGP encryption tool).
|
||||||
|
|
||||||
|
[Paste this DEVELOPMENT.md section]
|
||||||
|
[Paste relevant source files]
|
||||||
|
|
||||||
|
I want to add QR code generation. Here's my current seedpgp.ts...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-01-28
|
||||||
|
**Maintainer**: @kccleoc
|
||||||
|
```
|
||||||
|
|
||||||
|
Now commit it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add DEVELOPMENT.md
|
||||||
|
git commit -m "docs: add development guide with v1.2.0 QR agenda"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Ready for your next feature sprint! 🚀📋
|
||||||
387
GEMINI.md
Normal file
387
GEMINI.md
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
# SeedPGP - Gemini Code Assist Project Brief
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**SeedPGP v1.3.0**: Client-side BIP39 mnemonic encryption webapp
|
||||||
|
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
|
||||||
|
**Deploy**: GitHub Pages (public repo: `seedpgp-web-app`, private source: `seedpgp-web`)
|
||||||
|
**Live URL**: <https://kccleoc.github.io/seedpgp-web-app/>
|
||||||
|
|
||||||
|
## Core Constraints
|
||||||
|
|
||||||
|
1. **Security-first**: Never persist secrets (mnemonic/passphrase/private keys) to localStorage/sessionStorage/IndexedDB
|
||||||
|
2. **Small PRs**: Max 1-5 files per feature; propose plan before coding
|
||||||
|
3. **Client-side only**: No backend; all crypto runs in browser (Web Crypto API + OpenPGP.js)
|
||||||
|
4. **GitHub Pages deploy**: Base path `/seedpgp-web-app/` configured in vite.config.ts
|
||||||
|
5. **Honest security claims**: Don't overclaim what client-side JS can guarantee
|
||||||
|
|
||||||
|
## Non-Negotiables
|
||||||
|
|
||||||
|
- Small diffs only: one feature slice per PR (1-5 files if possible)
|
||||||
|
- No big code dumps; propose plan first, then implement
|
||||||
|
- Never persist secrets to browser storage
|
||||||
|
- Prefer "explain what you found in the repo" over guessing
|
||||||
|
- TypeScript strict mode; no `any` types without justification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Map
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
|
||||||
|
- `src/main.tsx` → `src/App.tsx` (main application)
|
||||||
|
- Build output: `dist/` (separate git repo for GitHub Pages deployment)
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```BASH
|
||||||
|
src/
|
||||||
|
├── components/ # React UI components
|
||||||
|
│ ├── PgpKeyInput.tsx
|
||||||
|
│ ├── QrDisplay.tsx
|
||||||
|
│ ├── QrScanner.tsx
|
||||||
|
│ ├── ReadOnly.tsx
|
||||||
|
│ ├── StorageIndicator.tsx
|
||||||
|
│ ├── SecurityWarnings.tsx
|
||||||
|
│ └── ClipboardTracker.tsx
|
||||||
|
├── lib/ # Core logic & crypto utilities
|
||||||
|
│ ├── seedpgp.ts # Main encrypt/decrypt functions
|
||||||
|
│ ├── sessionCrypto.ts # Ephemeral AES-GCM session keys
|
||||||
|
│ ├── types.ts # TypeScript interfaces
|
||||||
|
│ └── qr.ts # QR code utilities
|
||||||
|
├── App.tsx # Main app component
|
||||||
|
└── main.tsx # React entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Modules
|
||||||
|
|
||||||
|
#### `src/lib/seedpgp.ts`
|
||||||
|
|
||||||
|
Core encryption/decryption:
|
||||||
|
|
||||||
|
- `encryptToSeedPgp()` - Encrypts mnemonic with PGP public key + optional password
|
||||||
|
- `decryptFromSeedPgp()` - Decrypts with PGP private key + optional password
|
||||||
|
- Uses OpenPGP.js for PGP operations
|
||||||
|
- Output format: `SEEDPGP1:version:base64data:fingerprint`
|
||||||
|
|
||||||
|
#### `src/lib/sessionCrypto.ts` (v1.3.0+)
|
||||||
|
|
||||||
|
Ephemeral session-key encryption:
|
||||||
|
|
||||||
|
- `getSessionKey()` - Generates/returns non-exportable AES-GCM-256 key (idempotent)
|
||||||
|
- `encryptJsonToBlob(obj)` - Encrypts to `{v, alg, iv_b64, ct_b64}`
|
||||||
|
- `decryptBlobToJson(blob)` - Decrypts back to original object
|
||||||
|
- `destroySessionKey()` - Drops key reference for garbage collection
|
||||||
|
- Test: `await window.runSessionCryptoTest()` (DEV only)
|
||||||
|
|
||||||
|
#### `src/lib/types.ts`
|
||||||
|
|
||||||
|
Core interfaces:
|
||||||
|
|
||||||
|
- `SeedPgpPlaintext` - Decrypted mnemonic data structure
|
||||||
|
- `SeedPgpCiphertext` - Encrypted payload structure
|
||||||
|
- `EncryptedBlob` - Session-key encrypted cache format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### v1.0 - Core Functionality
|
||||||
|
|
||||||
|
- **Backup**: Encrypt mnemonic with PGP public key + optional password → QR display
|
||||||
|
- **Restore**: Scan/paste QR → decrypt with private key → show mnemonic
|
||||||
|
- **PGP support**: Import public/private keys (.asc files or paste)
|
||||||
|
|
||||||
|
### v1.1 - QR Features
|
||||||
|
|
||||||
|
- **QR Display**: Generate QR codes from encrypted data
|
||||||
|
- **QR Scanner**: Camera + file upload (uses html5-qrcode library)
|
||||||
|
|
||||||
|
### v1.2 - Security Monitoring
|
||||||
|
|
||||||
|
- **Storage Indicator**: Real-time display of localStorage/sessionStorage contents
|
||||||
|
- **Security Warnings**: Context-aware alerts about browser memory limitations
|
||||||
|
- **Clipboard Tracker**: Monitor clipboard operations on sensitive fields
|
||||||
|
- **Read-only Mode**: Toggle to clear state + show CSP/build info
|
||||||
|
|
||||||
|
### v1.3-v1.4 - Session-Key Encryption
|
||||||
|
|
||||||
|
- **Ephemeral encryption**: AES-GCM-256 session key (non-exportable) encrypts sensitive state
|
||||||
|
- **Backup flow (v1.3)**: Mnemonic auto-clears immediately after QR generation
|
||||||
|
- **Restore flow (v1.4)**: Decrypted mnemonic auto-clears after 10 seconds + manual Hide button
|
||||||
|
- **Encrypted cache**: Only ciphertext stored in React state; key lives in memory only
|
||||||
|
- **Lock/Clear**: Manual cleanup destroys session key + clears all state
|
||||||
|
- **Lifecycle**: Session key auto-destroyed on page close/refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install # Install dependencies
|
||||||
|
bun run dev # Dev server (localhost:5173)
|
||||||
|
bun run build # Build to dist/
|
||||||
|
bun run typecheck # TypeScript validation (tsc --noEmit)
|
||||||
|
bun run preview # Preview production build
|
||||||
|
./scripts/deploy.sh v1.x.x # Build + push to public repo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment Process
|
||||||
|
|
||||||
|
This project is now deployed to Cloudflare Pages for enhanced security.
|
||||||
|
|
||||||
|
1. **Private repo** (`seedpgp-web`): Source code, development
|
||||||
|
2. **Cloudflare Pages**: Deploys from `seedpgp-web` repo directly.
|
||||||
|
3. **GitHub Pages (Legacy)**: `seedpgp-web-app` public repo is retained for historical purposes, but no longer actively deployed to.
|
||||||
|
|
||||||
|
### Cloudflare Pages Deployment
|
||||||
|
|
||||||
|
1. Connect GitHub repo (`seedpgp-web`) to Cloudflare Pages.
|
||||||
|
2. Build settings: `bun run build`, output directory: `dist/`.
|
||||||
|
3. `public/_headers` file enforces Content Security Policy (CSP) and other security headers automatically.
|
||||||
|
4. Benefits: Real CSP enforcement, not just a UI toggle.
|
||||||
|
|
||||||
|
### Git Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commit feature
|
||||||
|
git add src/
|
||||||
|
git commit -m "feat(v1.x): description"
|
||||||
|
|
||||||
|
# Tag version
|
||||||
|
git tag v1.x.x
|
||||||
|
git push origin main --tags
|
||||||
|
|
||||||
|
# Deploy to GitHub Pages
|
||||||
|
./scripts/deploy.sh v1.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Workflow for AI Agents
|
||||||
|
|
||||||
|
### 1. Study First
|
||||||
|
|
||||||
|
Before implementing any feature:
|
||||||
|
|
||||||
|
- Read relevant files
|
||||||
|
- Explain current architecture + entry points
|
||||||
|
- List files that will be touched
|
||||||
|
- Identify potential conflicts or dependencies
|
||||||
|
|
||||||
|
### 2. Plan
|
||||||
|
|
||||||
|
- Propose smallest vertical slice (1-5 files)
|
||||||
|
- Show API signatures or interface changes first
|
||||||
|
- Get approval before generating full implementation
|
||||||
|
|
||||||
|
### 3. Implement
|
||||||
|
|
||||||
|
- Generate code with TypeScript strict mode
|
||||||
|
- Include JSDoc comments for public APIs
|
||||||
|
- Show unified diffs, not full file rewrites (when possible)
|
||||||
|
- Keep changes under 50-100 lines per file when feasible
|
||||||
|
|
||||||
|
### 4. Verify
|
||||||
|
|
||||||
|
- Run `bun run typecheck` - no errors
|
||||||
|
- Run `bun run build` - successful dist/ output
|
||||||
|
- Provide manual test steps for browser verification
|
||||||
|
- Show build output / console logs / DevTools screenshots
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- React `useState` + `useEffect` (no Redux/Zustand/external store)
|
||||||
|
- Ephemeral state only; avoid persistent storage for secrets
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- Tailwind utility classes (configured in `tailwind.config.js`)
|
||||||
|
- Responsive design: mobile-first with `md:` breakpoints
|
||||||
|
- Dark theme primary: slate-900 background, blue-400 accents
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
- `lucide-react` library
|
||||||
|
- Common: Shield, QrCode, Lock, Eye, AlertCircle
|
||||||
|
|
||||||
|
### Crypto Operations
|
||||||
|
|
||||||
|
- **PGP**: OpenPGP.js (`openpgp` package)
|
||||||
|
- **Session keys**: Web Crypto API (`crypto.subtle`)
|
||||||
|
- **Key generation**: `crypto.subtle.generateKey()` with `extractable: false`
|
||||||
|
- **Encryption**: AES-GCM with random 12-byte IV per operation
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
|
||||||
|
- Strict TypeScript (`tsconfig.json`: `strict: true`)
|
||||||
|
- Check `src/lib/types.ts` for core interfaces
|
||||||
|
- Avoid `any`; use `unknown` + type guards when necessary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Architecture
|
||||||
|
|
||||||
|
### Threat Model (Honest)
|
||||||
|
|
||||||
|
**What we protect against:**
|
||||||
|
|
||||||
|
- Accidental persistence to localStorage/sessionStorage
|
||||||
|
- Plaintext secrets lingering in React state after use
|
||||||
|
- Clipboard history exposure (with warnings)
|
||||||
|
|
||||||
|
**What we DON'T protect against (and must not claim to):**
|
||||||
|
|
||||||
|
- Active XSS or malicious browser extensions
|
||||||
|
- Memory dumps or browser crash reports
|
||||||
|
- JavaScript garbage collection timing (non-deterministic)
|
||||||
|
|
||||||
|
### Memory Handling
|
||||||
|
|
||||||
|
- **Session keys**: Non-exportable CryptoKey objects (Web Crypto API)
|
||||||
|
- **Plaintext clearing**: Set to empty string + drop references (but GC timing is non-deterministic)
|
||||||
|
- **No guarantees**: Cannot force immediate memory wiping in JavaScript
|
||||||
|
|
||||||
|
### Storage Policy
|
||||||
|
|
||||||
|
- **NEVER write to**: localStorage, sessionStorage, IndexedDB, cookies
|
||||||
|
- **Exception**: Non-sensitive UI state only (theme preferences, etc.) - NOT IMPLEMENTED YET
|
||||||
|
- **Verification**: StorageIndicator component monitors all storage APIs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What NOT to Do
|
||||||
|
|
||||||
|
### Code Generation
|
||||||
|
|
||||||
|
- Don't generate full file rewrites unless necessary
|
||||||
|
- Don't add dependencies without discussing bundle size impact
|
||||||
|
- Don't use `any` types without explicit justification
|
||||||
|
- Don't skip TypeScript strict mode checks
|
||||||
|
|
||||||
|
### Security Claims
|
||||||
|
|
||||||
|
- Don't claim "RAM is wiped" (JavaScript can't force GC)
|
||||||
|
- Don't claim "offline mode" without real CSP headers (GitHub Pages can't set custom headers)
|
||||||
|
- Don't promise protection against active browser compromise (XSS/extensions)
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
- Don't write secrets to storage without explicit approval
|
||||||
|
- Don't cache decrypted data beyond immediate use
|
||||||
|
- Don't assume browser storage is secure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Verification
|
||||||
|
|
||||||
|
### Manual Test Checklist (Before Marking Feature Complete)
|
||||||
|
|
||||||
|
1. ✅ `bun run typecheck` passes (no TypeScript errors)
|
||||||
|
2. ✅ `bun run build` succeeds (dist/ generated)
|
||||||
|
3. ✅ Browser test: Feature works as described
|
||||||
|
4. ✅ DevTools Console: No runtime errors
|
||||||
|
5. ✅ DevTools Application tab: No plaintext secrets in storage
|
||||||
|
6. ✅ DevTools Network tab: No unexpected network calls (if Read-only Mode)
|
||||||
|
|
||||||
|
### Session-Key Encryption Test (v1.3+)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In browser DevTools console:
|
||||||
|
await window.runSessionCryptoTest()
|
||||||
|
// Expected: ✅ Success: Data integrity verified.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Version: v1.4.0
|
||||||
|
|
||||||
|
### Recent Changes (2026-01-30)
|
||||||
|
- ✅ Extended session-key encryption to Restore flow
|
||||||
|
- ✅ Added 10-second auto-clear timer for restored mnemonic
|
||||||
|
- ✅ Added Hide button for manual clear
|
||||||
|
- ✅ Removed debug console logs from sessionCrypto.ts
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- GitHub Pages cannot set custom CSP headers (need Cloudflare Pages for enforcement)
|
||||||
|
- Read-only Mode is UI-level only (not browser-enforced)
|
||||||
|
- Session-key encryption doesn't protect against active XSS/extensions
|
||||||
|
|
||||||
|
### Next Priorities (Suggested)
|
||||||
|
|
||||||
|
1. Extend session-key encryption to Restore flow
|
||||||
|
2. Migrate to Cloudflare Pages for real CSP header enforcement
|
||||||
|
3. Add "Encrypted in memory" badge when encryptedMnemonicCache exists
|
||||||
|
4. Document reproducible builds (git hash verification)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### File a Bug/Feature
|
||||||
|
|
||||||
|
1. Describe expected vs actual behavior
|
||||||
|
2. Include browser console errors (if any)
|
||||||
|
3. Specify which flow (Backup/Restore/QR Scanner)
|
||||||
|
|
||||||
|
### Roll Over to Next Session
|
||||||
|
|
||||||
|
Always provide:
|
||||||
|
|
||||||
|
- Current version number
|
||||||
|
- What was implemented this session
|
||||||
|
- Files modified
|
||||||
|
- What still needs work
|
||||||
|
- Any gotchas or edge cases discovered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Prompts for Gemini
|
||||||
|
|
||||||
|
### Exploration
|
||||||
|
|
||||||
|
```
|
||||||
|
Read GEMINI.md, then explain:
|
||||||
|
1. Where is the mnemonic textarea and how is its value managed?
|
||||||
|
2. List all places localStorage/sessionStorage are used
|
||||||
|
3. Show data flow from "Backup" button to QR display
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Request
|
||||||
|
|
||||||
|
```
|
||||||
|
Task: [Feature description]
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
1. [Specific requirement]
|
||||||
|
2. [Another requirement]
|
||||||
|
|
||||||
|
Files to touch:
|
||||||
|
- [List files]
|
||||||
|
|
||||||
|
Plan first: show proposed API/changes before generating code.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```
|
||||||
|
Audit the codebase to verify [feature] is fully implemented.
|
||||||
|
Check:
|
||||||
|
1. [Requirement 1]
|
||||||
|
2. [Requirement 2]
|
||||||
|
Output: ✅ or ❌ for each item + suggest fixes for failures.
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-01-29
|
||||||
|
**Maintained by**: @kccleoc
|
||||||
|
**AI Agent**: Optimized for Gemini Code Assist
|
||||||
411
README.md
411
README.md
@@ -1,73 +1,366 @@
|
|||||||
# React + TypeScript + Vite
|
# SeedPGP v1.1.0
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
**Secure BIP39 mnemonic backup using PGP encryption and QR codes**
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
A TypeScript/Bun tool for encrypting cryptocurrency seed phrases with OpenPGP and encoding them as QR-friendly Base45 frames with CRC16 integrity checking.
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
## Features
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
- 🔐 **PGP Encryption**: Uses cv25519 (Curve25519) for modern elliptic curve cryptography
|
||||||
|
- 📱 **QR Code Ready**: Base45 encoding optimized for QR code generation
|
||||||
|
- ✅ **Integrity Checking**: CRC16-CCITT-FALSE checksums prevent corruption
|
||||||
|
- 🔑 **BIP39 Support**: Full support for 12/18/24-word mnemonics with passphrase indicator
|
||||||
|
- 🧪 **Battle-Tested**: Validated against official Trezor BIP39 test vectors
|
||||||
|
- ⚡ **Fast**: Built with Bun runtime for optimal performance
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
## Installation
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/kccleoc/seedpgp-web.git
|
||||||
|
cd seedpgp-web
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
```js
|
# Run tests
|
||||||
export default defineConfig([
|
bun test
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
# Start development server
|
||||||
tseslint.configs.recommendedTypeChecked,
|
bun run dev
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
## Usage
|
||||||
|
|
||||||
```js
|
### Encrypt a Mnemonic
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
```typescript
|
||||||
globalIgnores(['dist']),
|
import { encryptToSeedPgp, buildPlaintext } from "./lib/seedpgp";
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
const mnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow";
|
||||||
extends: [
|
const plaintext = buildPlaintext(mnemonic, false); // false = no BIP39 passphrase used
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
const result = await encryptToSeedPgp({
|
||||||
reactX.configs['recommended-typescript'],
|
plaintext,
|
||||||
// Enable lint rules for React DOM
|
publicKeyArmored: yourPgpPublicKey,
|
||||||
reactDom.configs.recommended,
|
});
|
||||||
],
|
|
||||||
languageOptions: {
|
console.log(result.framed); // SEEDPGP1:0:ABCD:BASE45DATA...
|
||||||
parserOptions: {
|
console.log(result.recipientFingerprint); // Key fingerprint for verification
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
```
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
### Decrypt a SeedPGP Frame
|
||||||
// other options...
|
|
||||||
},
|
```typescript
|
||||||
},
|
import { decryptSeedPgp } from "./lib/seedpgp";
|
||||||
])
|
|
||||||
|
const decrypted = await decryptSeedPgp({
|
||||||
|
frameText: "SEEDPGP1:0:ABCD:BASE45DATA...",
|
||||||
|
privateKeyArmored: yourPrivateKey,
|
||||||
|
privateKeyPassphrase: "your-key-password",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(decrypted.w); // Recovered mnemonic
|
||||||
|
console.log(decrypted.pp); // BIP39 passphrase indicator (0 or 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment to GitHub Pages (FREE)
|
||||||
|
|
||||||
|
This project uses a two-repository setup to keep source code private while hosting the app for free.
|
||||||
|
|
||||||
|
### One-Time Setup
|
||||||
|
|
||||||
|
#### 1. Create Public Deployment Repo
|
||||||
|
|
||||||
|
Go to https://github.com/new and create:
|
||||||
|
- **Name**: `seedpgp-web-app` (or any name you prefer)
|
||||||
|
- **Visibility**: **Public**
|
||||||
|
- **Don't** initialize with README, .gitignore, or license
|
||||||
|
|
||||||
|
#### 2. Configure Vite Base Path
|
||||||
|
|
||||||
|
Edit `vite.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/seedpgp-web-app/', // Match your public repo name
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Build and Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the production bundle
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Initialize git in dist folder
|
||||||
|
cd dist
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Deploy seedpgp v1.1.0"
|
||||||
|
|
||||||
|
# Push to your public repo
|
||||||
|
git remote add origin https://github.com/kccleoc/seedpgp-web-app.git
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main
|
||||||
|
|
||||||
|
# Return to project root
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Enable GitHub Pages
|
||||||
|
|
||||||
|
1. Go to `https://github.com/kccleoc/seedpgp-web-app/settings/pages`
|
||||||
|
2. **Source**: Deploy from a branch
|
||||||
|
3. **Branch**: Select `main` → `/` (root)
|
||||||
|
4. Click **Save**
|
||||||
|
|
||||||
|
Wait 1-2 minutes, then visit: **https://kccleoc.github.io/seedpgp-web-app/**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Deploying Updates (v1.2.0, v1.3.0, etc.)
|
||||||
|
|
||||||
|
Create `scripts/deploy.sh` in your project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION=$1
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "Usage: ./scripts/deploy.sh v1.2.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔨 Building $VERSION..."
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
echo "📦 Deploying to GitHub Pages..."
|
||||||
|
cd dist
|
||||||
|
git add .
|
||||||
|
git commit -m "Deploy $VERSION" || echo "No changes to commit"
|
||||||
|
git push
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
|
||||||
|
echo "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
|
||||||
|
```
|
||||||
|
|
||||||
|
Make executable and use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/deploy.sh
|
||||||
|
./scripts/deploy.sh v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Repository Structure
|
||||||
|
|
||||||
|
- **seedpgp-web** (Private) - Your source code, active development
|
||||||
|
- **seedpgp-web-app** (Public) - Built files only, served via GitHub Pages
|
||||||
|
|
||||||
|
**Cost: $0/month** ✅
|
||||||
|
|
||||||
|
## Frame Format
|
||||||
|
|
||||||
|
```
|
||||||
|
SEEDPGP1:FRAME:CRC16:BASE45DATA
|
||||||
|
|
||||||
|
SEEDPGP1 - Protocol identifier and version
|
||||||
|
0 - Frame number (0 = single frame)
|
||||||
|
ABCD - 4-digit hex CRC16-CCITT-FALSE checksum
|
||||||
|
BASE45 - Base45-encoded PGP message
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `buildPlaintext(mnemonic, bip39PassphraseUsed, recipientFingerprints?)`
|
||||||
|
|
||||||
|
Creates a SeedPGP plaintext object.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `mnemonic` (string): BIP39 mnemonic phrase (12/18/24 words)
|
||||||
|
- `bip39PassphraseUsed` (boolean): Whether a BIP39 passphrase was used
|
||||||
|
- `recipientFingerprints` (string[]): Optional array of recipient key fingerprints
|
||||||
|
|
||||||
|
**Returns:** `SeedPgpPlaintext` object
|
||||||
|
|
||||||
|
### `encryptToSeedPgp(params)`
|
||||||
|
|
||||||
|
Encrypts a plaintext object to SeedPGP format.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
plaintext: SeedPgpPlaintext;
|
||||||
|
publicKeyArmored?: string; // PGP public key (PKESK)
|
||||||
|
messagePassword?: string; // Symmetric password (SKESK)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
framed: string; // SEEDPGP1 frame
|
||||||
|
pgpBytes: Uint8Array; // Raw PGP message
|
||||||
|
recipientFingerprint?: string; // Key fingerprint
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `decryptSeedPgp(params)`
|
||||||
|
|
||||||
|
Decrypts a SeedPGP frame.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
frameText: string; // SEEDPGP1 frame
|
||||||
|
privateKeyArmored?: string; // PGP private key
|
||||||
|
privateKeyPassphrase?: string; // Key unlock password
|
||||||
|
messagePassword?: string; // SKESK password
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:** `SeedPgpPlaintext` object
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
bun test --verbose
|
||||||
|
|
||||||
|
# Watch mode (auto-rerun on changes)
|
||||||
|
bun test --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
- ✅ 15 comprehensive tests
|
||||||
|
- ✅ 8 official Trezor BIP39 test vectors
|
||||||
|
- ✅ Edge cases (wrong key, wrong passphrase)
|
||||||
|
- ✅ Frame format validation
|
||||||
|
- ✅ CRC16 integrity checking
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### ✅ Best Practices
|
||||||
|
|
||||||
|
- Uses **AES-256** for symmetric encryption
|
||||||
|
- **cv25519** provides ~128-bit security level
|
||||||
|
- **CRC16** detects QR scan errors (not cryptographic)
|
||||||
|
- Key fingerprint validation prevents wrong-key usage
|
||||||
|
|
||||||
|
### ⚠️ Important Notes
|
||||||
|
|
||||||
|
- **Never share your private key or encrypted QR codes publicly**
|
||||||
|
- Store backup QR codes in secure physical locations (safe, safety deposit box)
|
||||||
|
- Use a strong PGP key passphrase (20+ characters)
|
||||||
|
- Test decryption immediately after generating backups
|
||||||
|
- Consider password-only (SKESK) encryption as additional fallback
|
||||||
|
|
||||||
|
### 🔒 Production Deployment Warning
|
||||||
|
|
||||||
|
The GitHub Pages deployment at **https://kccleoc.github.io/seedpgp-web-app/** is for:
|
||||||
|
- ✅ Testing and demonstration
|
||||||
|
- ✅ Convenient access for personal use
|
||||||
|
- ⚠️ Always verify the URL before use
|
||||||
|
|
||||||
|
For maximum security with real funds:
|
||||||
|
- Run locally: `bun run dev`
|
||||||
|
- Or self-host on your own domain with HTTPS
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
seedpgp-web/
|
||||||
|
├── src/
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── seedpgp.ts # Core encryption/decryption
|
||||||
|
│ │ ├── seedpgp.test.ts # Test vectors
|
||||||
|
│ │ ├── base45.ts # Base45 codec
|
||||||
|
│ │ ├── crc16.ts # CRC16-CCITT-FALSE
|
||||||
|
│ │ └── types.ts # TypeScript definitions
|
||||||
|
│ └── App.tsx # React UI
|
||||||
|
├── scripts/
|
||||||
|
│ └── deploy.sh # Deployment automation
|
||||||
|
├── package.json
|
||||||
|
├── DEVELOPMENT.md # Development guide
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: [Bun](https://bun.sh) v1.3.6+
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **Crypto**: [OpenPGP.js](https://openpgpjs.org) v6.3.0
|
||||||
|
- **Framework**: React + Vite
|
||||||
|
- **Testing**: Bun test runner
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] QR code generation UI
|
||||||
|
- [ ] QR code scanner with camera support
|
||||||
|
- [ ] Multi-frame support for larger payloads
|
||||||
|
- [ ] Hardware wallet integration
|
||||||
|
- [ ] Mobile scanning app
|
||||||
|
- [ ] Shamir Secret Sharing support
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
**kccleoc** - [GitHub](https://github.com/kccleoc)
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
### v1.1.0 (2026-01-28)
|
||||||
|
- Initial public release
|
||||||
|
- Full BIP39 mnemonic support
|
||||||
|
- Trezor test vector validation
|
||||||
|
- Production-ready implementation
|
||||||
|
- GitHub Pages deployment guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
⚠️ **Disclaimer**: This software is provided as-is. Always test thoroughly before trusting with real funds. The author is not responsible for lost funds due to software bugs or user error.
|
||||||
|
|
||||||
|
Now create the deployment script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p scripts
|
||||||
|
cat > scripts/deploy.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION=$1
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "Usage: ./scripts/deploy.sh v1.2.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔨 Building $VERSION..."
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
echo "📦 Deploying to GitHub Pages..."
|
||||||
|
cd dist
|
||||||
|
git add .
|
||||||
|
git commit -m "Deploy $VERSION" || echo "No changes to commit"
|
||||||
|
git push
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
|
||||||
|
echo "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x scripts/deploy.sh
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
Next steps (optional):
|
|
||||||
QR code generation - Add library to generate QR codes from encrypted.framed
|
|
||||||
|
|
||||||
UI integration - Connect to your React components
|
|
||||||
|
|
||||||
Multi-frame support - If you need to handle larger payloads (though mnemonics fit in single frame)
|
|
||||||
|
|
||||||
Password-only mode - Test SKESK-only encryption for backup scenarios
|
|
||||||
|
|
||||||
Your crypto wallet backup tool has a solid foundation! 🔐✨
|
|
||||||
17
bun.lock
17
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "seedpgp-web",
|
"name": "seedpgp-web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"openpgp": "^6.3.0",
|
"openpgp": "^6.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -195,7 +196,7 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
@@ -223,7 +224,7 @@
|
|||||||
|
|
||||||
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
@@ -231,7 +232,7 @@
|
|||||||
|
|
||||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
||||||
|
|
||||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
@@ -297,6 +298,8 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="],
|
||||||
|
|
||||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
@@ -459,12 +462,8 @@
|
|||||||
|
|
||||||
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||||
|
|
||||||
"@types/qrcode/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="],
|
|
||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="],
|
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -472,9 +471,5 @@
|
|||||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"@types/qrcode/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
||||||
|
|
||||||
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SeedPGP v1.1</title>
|
<title>SeedPGP v1.4</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "seedpgp-web",
|
"name": "seedpgp-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.4.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"openpgp": "^6.3.0",
|
"openpgp": "^6.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
121
public/README.md
Normal file
121
public/README.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# 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
|
||||||
6
public/_headers
Normal file
6
public/_headers
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/*
|
||||||
|
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; form-action 'none'; base-uri 'self';
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-XSS-Protection: 1; mode=block
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
35
scripts/deploy.sh
Executable file
35
scripts/deploy.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION=$1
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "Usage: ./scripts/deploy.sh v1.2.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔨 Building $VERSION..."
|
||||||
|
# Remove old build files but keep .git
|
||||||
|
rm -rf dist/assets dist/index.html dist/*.js dist/*.css dist/vite.svg
|
||||||
|
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
echo "📄 Adding README..."
|
||||||
|
if [ -f public/README.md ]; then
|
||||||
|
cp public/README.md dist/README.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Deploying to GitHub Pages..."
|
||||||
|
cd dist
|
||||||
|
|
||||||
|
git add .
|
||||||
|
git commit -m "Deploy $VERSION" || echo "No changes to commit"
|
||||||
|
git push
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
|
||||||
|
echo ""
|
||||||
|
echo "Tag private repo:"
|
||||||
|
echo " git tag $VERSION && git push origin --tags"
|
||||||
253
src/App.tsx
253
src/App.tsx
@@ -1,48 +1,85 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
QrCode,
|
QrCode,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
CheckCircle2,
|
CheckCircle2, Lock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Lock,
|
|
||||||
Unlock,
|
Unlock,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FileKey,
|
FileKey,
|
||||||
Info
|
Info,
|
||||||
|
WifiOff
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PgpKeyInput } from './components/PgpKeyInput';
|
import { PgpKeyInput } from './components/PgpKeyInput';
|
||||||
import { QrDisplay } from './components/QrDisplay';
|
import { QrDisplay } from './components/QrDisplay';
|
||||||
|
import QRScanner from './components/QRScanner';
|
||||||
import { validateBip39Mnemonic } from './lib/bip39';
|
import { validateBip39Mnemonic } from './lib/bip39';
|
||||||
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
|
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
|
||||||
import type { SeedPgpPlaintext } from './lib/types';
|
|
||||||
import * as openpgp from 'openpgp';
|
import * as openpgp from 'openpgp';
|
||||||
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
import { StorageIndicator } from './components/StorageIndicator';
|
||||||
|
import { SecurityWarnings } from './components/SecurityWarnings';
|
||||||
|
import { ClipboardTracker } from './components/ClipboardTracker';
|
||||||
|
import { ReadOnly } from './components/ReadOnly';
|
||||||
|
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
|
||||||
|
|
||||||
function App() {
|
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
||||||
|
|
||||||
|
function App() {
|
||||||
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
|
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
|
||||||
const [mnemonic, setMnemonic] = useState('');
|
const [mnemonic, setMnemonic] = useState('');
|
||||||
const [messagePassword, setMessagePassword] = useState('');
|
const [backupMessagePassword, setBackupMessagePassword] = useState('');
|
||||||
const [pgpKeyInput, setPgpKeyInput] = useState('');
|
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
|
||||||
|
|
||||||
|
const [publicKeyInput, setPublicKeyInput] = useState('');
|
||||||
|
const [privateKeyInput, setPrivateKeyInput] = useState('');
|
||||||
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
|
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
|
||||||
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
|
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
|
||||||
const [qrPayload, setQrPayload] = useState('');
|
const [qrPayload, setQrPayload] = useState('');
|
||||||
const [recipientFpr, setRecipientFpr] = useState('');
|
const [recipientFpr, setRecipientFpr] = useState('');
|
||||||
const [restoreInput, setRestoreInput] = useState('');
|
const [restoreInput, setRestoreInput] = useState('');
|
||||||
const [restoredData, setRestoredData] = useState<SeedPgpPlaintext | null>(null);
|
const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showMnemonic, setShowMnemonic] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showQRScanner, setShowQRScanner] = useState(false);
|
||||||
|
const [isReadOnly, setIsReadOnly] = useState(false);
|
||||||
|
const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// When entering read-only mode, clear sensitive data for security.
|
||||||
|
if (isReadOnly) {
|
||||||
|
setMnemonic('');
|
||||||
|
setBackupMessagePassword('');
|
||||||
|
setRestoreMessagePassword('');
|
||||||
|
setPublicKeyInput('');
|
||||||
|
setPrivateKeyInput('');
|
||||||
|
setPrivateKeyPassphrase('');
|
||||||
|
setQrPayload('');
|
||||||
|
setRestoreInput('');
|
||||||
|
setDecryptedRestoredMnemonic(null);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [isReadOnly]);
|
||||||
|
|
||||||
|
// Cleanup session key on component unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
destroySessionKey();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
|
if (isReadOnly) {
|
||||||
|
setError("Copy to clipboard is disabled in Read-only mode.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
window.setTimeout(() => setCopied(false), 1500);
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback for environments where Clipboard API is blocked
|
|
||||||
const ta = document.createElement("textarea");
|
const ta = document.createElement("textarea");
|
||||||
ta.value = text;
|
ta.value = text;
|
||||||
ta.style.position = "fixed";
|
ta.style.position = "fixed";
|
||||||
@@ -57,7 +94,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleBackup = async () => {
|
const handleBackup = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -74,14 +110,21 @@ function App() {
|
|||||||
|
|
||||||
const result = await encryptToSeedPgp({
|
const result = await encryptToSeedPgp({
|
||||||
plaintext,
|
plaintext,
|
||||||
publicKeyArmored: pgpKeyInput || undefined,
|
publicKeyArmored: publicKeyInput || undefined,
|
||||||
messagePassword: messagePassword || undefined,
|
messagePassword: backupMessagePassword || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setQrPayload(result.framed);
|
setQrPayload(result.framed);
|
||||||
if (result.recipientFingerprint) {
|
if (result.recipientFingerprint) {
|
||||||
setRecipientFpr(result.recipientFingerprint);
|
setRecipientFpr(result.recipientFingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize session key before encrypting
|
||||||
|
await getSessionKey();
|
||||||
|
// Encrypt mnemonic with session key and clear plaintext state
|
||||||
|
const blob = await encryptJsonToBlob({ mnemonic, timestamp: Date.now() });
|
||||||
|
setEncryptedMnemonicCache(blob);
|
||||||
|
setMnemonic(''); // Clear plaintext mnemonic
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Encryption failed');
|
setError(e instanceof Error ? e.message : 'Encryption failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,17 +135,27 @@ function App() {
|
|||||||
const handleRestore = async () => {
|
const handleRestore = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
setRestoredData(null);
|
setDecryptedRestoredMnemonic(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await decryptSeedPgp({
|
const result = await decryptSeedPgp({
|
||||||
frameText: restoreInput,
|
frameText: restoreInput,
|
||||||
privateKeyArmored: pgpKeyInput || undefined,
|
privateKeyArmored: privateKeyInput || undefined,
|
||||||
privateKeyPassphrase: privateKeyPassphrase || undefined,
|
privateKeyPassphrase: privateKeyPassphrase || undefined,
|
||||||
messagePassword: messagePassword || undefined,
|
messagePassword: restoreMessagePassword || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setRestoredData(result);
|
// Encrypt the restored mnemonic with the session key
|
||||||
|
await getSessionKey();
|
||||||
|
const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() });
|
||||||
|
setEncryptedMnemonicCache(blob);
|
||||||
|
|
||||||
|
// Temporarily display the mnemonic and then clear it
|
||||||
|
setDecryptedRestoredMnemonic(result.w);
|
||||||
|
setTimeout(() => {
|
||||||
|
setDecryptedRestoredMnemonic(null);
|
||||||
|
}, 10000); // Auto-clear after 10 seconds
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Decryption failed');
|
setError(e instanceof Error ? e.message : 'Decryption failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,7 +163,27 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLockAndClear = () => {
|
||||||
|
destroySessionKey();
|
||||||
|
setEncryptedMnemonicCache(null);
|
||||||
|
setMnemonic('');
|
||||||
|
setBackupMessagePassword('');
|
||||||
|
setRestoreMessagePassword('');
|
||||||
|
setPublicKeyInput('');
|
||||||
|
setPrivateKeyInput('');
|
||||||
|
setPrivateKeyPassphrase('');
|
||||||
|
setQrPayload('');
|
||||||
|
setRecipientFpr('');
|
||||||
|
setRestoreInput('');
|
||||||
|
setDecryptedRestoredMnemonic(null);
|
||||||
|
setError('');
|
||||||
|
setCopied(false);
|
||||||
|
setShowQRScanner(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8">
|
||||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
|
||||||
|
|
||||||
@@ -122,18 +195,40 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.1</span>
|
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v{__APP_VERSION__}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
|
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{encryptedMnemonicCache && ( // Show only if encrypted data exists
|
||||||
|
<button
|
||||||
|
onClick={handleLockAndClear}
|
||||||
|
className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Lock size={16} />
|
||||||
|
<span>Lock/Clear</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isReadOnly && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
|
||||||
|
<WifiOff size={16} />
|
||||||
|
<span>Read-only</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{encryptedMnemonicCache && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
|
||||||
|
<Shield size={16} />
|
||||||
|
<span>Encrypted in memory</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
|
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab('backup');
|
setActiveTab('backup');
|
||||||
setError('');
|
setError('');
|
||||||
setQrPayload('');
|
setQrPayload('');
|
||||||
setRestoredData(null);
|
setDecryptedRestoredMnemonic(null);
|
||||||
}}
|
}}
|
||||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
|
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
|
||||||
? 'bg-white text-slate-900 shadow-lg'
|
? 'bg-white text-slate-900 shadow-lg'
|
||||||
@@ -147,7 +242,7 @@ function App() {
|
|||||||
setActiveTab('restore');
|
setActiveTab('restore');
|
||||||
setError('');
|
setError('');
|
||||||
setQrPayload('');
|
setQrPayload('');
|
||||||
setRestoredData(null);
|
setDecryptedRestoredMnemonic(null);
|
||||||
}}
|
}}
|
||||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
|
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
|
||||||
? 'bg-white text-slate-900 shadow-lg'
|
? 'bg-white text-slate-900 shadow-lg'
|
||||||
@@ -158,6 +253,7 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-6 md:p-8 space-y-6">
|
<div className="p-6 md:p-8 space-y-6">
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
@@ -190,9 +286,11 @@ function App() {
|
|||||||
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
|
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
||||||
|
data-sensitive="BIP39 Mnemonic"
|
||||||
placeholder="Enter your 12 or 24 word seed phrase..."
|
placeholder="Enter your 12 or 24 word seed phrase..."
|
||||||
value={mnemonic}
|
value={mnemonic}
|
||||||
onChange={(e) => setMnemonic(e.target.value)}
|
onChange={(e) => setMnemonic(e.target.value)}
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -200,12 +298,24 @@ function App() {
|
|||||||
label="PGP Public Key (Optional)"
|
label="PGP Public Key (Optional)"
|
||||||
icon={FileKey}
|
icon={FileKey}
|
||||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- Paste or drag & drop your public key..."
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- Paste or drag & drop your public key..."
|
||||||
value={pgpKeyInput}
|
value={publicKeyInput}
|
||||||
onChange={setPgpKeyInput}
|
onChange={setPublicKeyInput}
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQRScanner(true)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
className="flex-1 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-purple-700 hover:to-purple-800 transition-all shadow-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<QrCode size={18} />
|
||||||
|
Scan QR Code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
|
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -213,28 +323,33 @@ function App() {
|
|||||||
placeholder="SEEDPGP1:0:ABCD:..."
|
placeholder="SEEDPGP1:0:ABCD:..."
|
||||||
value={restoreInput}
|
value={restoreInput}
|
||||||
onChange={(e) => setRestoreInput(e.target.value)}
|
onChange={(e) => setRestoreInput(e.target.value)}
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PgpKeyInput
|
<PgpKeyInput
|
||||||
label="PGP Private Key (Optional)"
|
label="PGP Private Key (Optional)"
|
||||||
icon={FileKey}
|
icon={FileKey}
|
||||||
|
data-sensitive="PGP Private Key"
|
||||||
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
||||||
value={pgpKeyInput}
|
value={privateKeyInput}
|
||||||
onChange={setPgpKeyInput}
|
onChange={setPrivateKeyInput}
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pgpKeyInput && (
|
{privateKeyInput && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
data-sensitive="Message Password"
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
placeholder="Unlock private key..."
|
placeholder="Unlock private key..."
|
||||||
value={privateKeyPassphrase}
|
value={privateKeyPassphrase}
|
||||||
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
|
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,13 +373,15 @@ function App() {
|
|||||||
type="password"
|
type="password"
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
placeholder="Optional password..."
|
placeholder="Optional password..."
|
||||||
value={messagePassword}
|
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
|
||||||
onChange={(e) => setMessagePassword(e.target.value)}
|
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
|
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{activeTab === 'backup' && (
|
{activeTab === 'backup' && (
|
||||||
<div className="pt-3 border-t border-slate-300">
|
<div className="pt-3 border-t border-slate-300">
|
||||||
<label className="flex items-center gap-2 cursor-pointer group">
|
<label className="flex items-center gap-2 cursor-pointer group">
|
||||||
@@ -272,6 +389,7 @@ function App() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={hasBip39Passphrase}
|
checked={hasBip39Passphrase}
|
||||||
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
|
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
|
||||||
|
disabled={isReadOnly}
|
||||||
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
|
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||||
@@ -280,13 +398,20 @@ function App() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ReadOnly
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
onToggle={setIsReadOnly}
|
||||||
|
appVersion={__APP_VERSION__}
|
||||||
|
buildHash={__BUILD_HASH__}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
{activeTab === 'backup' ? (
|
{activeTab === 'backup' ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleBackup}
|
onClick={handleBackup}
|
||||||
disabled={!mnemonic || loading}
|
disabled={!mnemonic || loading || isReadOnly}
|
||||||
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-blue-600 disabled:hover:to-blue-700"
|
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-blue-600 disabled:hover:to-blue-700"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -299,7 +424,7 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleRestore}
|
onClick={handleRestore}
|
||||||
disabled={!restoreInput || loading}
|
disabled={!restoreInput || loading || isReadOnly}
|
||||||
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -345,12 +470,11 @@ function App() {
|
|||||||
Tip: click the box to select all, or use Copy.
|
Tip: click the box to select all, or use Copy.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Restored Mnemonic */}
|
{/* Restored Mnemonic */}
|
||||||
{restoredData && activeTab === 'restore' && (
|
{decryptedRestoredMnemonic && activeTab === 'restore' && (
|
||||||
<div className="pt-6 border-t border-slate-200 animate-in zoom-in-95">
|
<div className="pt-6 border-t border-slate-200 animate-in zoom-in-95">
|
||||||
<div className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
|
<div className="p-6 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -358,35 +482,18 @@ function App() {
|
|||||||
<CheckCircle2 size={22} /> Mnemonic Recovered
|
<CheckCircle2 size={22} /> Mnemonic Recovered
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMnemonic(!showMnemonic)}
|
onClick={() => setDecryptedRestoredMnemonic(null)}
|
||||||
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
|
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
|
||||||
>
|
>
|
||||||
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />}
|
<EyeOff size={22} /> Hide
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm transition-all duration-300 ${showMnemonic ? 'blur-0' : 'blur-lg select-none'
|
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm">
|
||||||
}`}>
|
|
||||||
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
|
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
|
||||||
{restoredData.w}
|
{decryptedRestoredMnemonic}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{restoredData.pp === 1 && (
|
|
||||||
<div className="mt-4 p-3 bg-orange-100 border border-orange-300 rounded-lg">
|
|
||||||
<p className="text-xs text-center text-orange-800 font-bold uppercase tracking-widest flex items-center justify-center gap-2">
|
|
||||||
<AlertCircle size={14} /> BIP39 Passphrase Required (25th Word)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{restoredData.fpr && restoredData.fpr.length > 0 && (
|
|
||||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
||||||
<p className="text-xs text-blue-800">
|
|
||||||
<strong>Encrypted for keys:</strong> {restoredData.fpr.join(', ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -395,11 +502,39 @@ function App() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-8 text-center text-xs text-slate-500">
|
<div className="mt-8 text-center text-xs text-slate-500">
|
||||||
<p>SeedPGP v1.1 • OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p>
|
<p>SeedPGP v{__APP_VERSION__} • OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p>
|
||||||
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
|
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
{/* QR Scanner Modal */}
|
||||||
|
{showQRScanner && (
|
||||||
|
<QRScanner
|
||||||
|
onScanSuccess={(scannedText) => {
|
||||||
|
setRestoreInput(scannedText);
|
||||||
|
setShowQRScanner(false);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
onClose={() => setShowQRScanner(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
|
<h1>SeedPGP v1.2.0</h1>
|
||||||
|
{/* ... rest of your app ... */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Storage Monitor - bottom right */}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<StorageIndicator />
|
||||||
|
<SecurityWarnings />
|
||||||
|
<ClipboardTracker />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
184
src/components/ClipboardTracker.tsx
Normal file
184
src/components/ClipboardTracker.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ClipboardEvent {
|
||||||
|
timestamp: Date;
|
||||||
|
field: string;
|
||||||
|
length: number; // Show length without storing actual content
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClipboardTracker = () => {
|
||||||
|
const [events, setEvents] = useState<ClipboardEvent[]>([]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCopy = (e: ClipboardEvent & Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Get selection to measure length
|
||||||
|
const selection = window.getSelection()?.toString() || '';
|
||||||
|
const length = selection.length;
|
||||||
|
|
||||||
|
if (length === 0) return; // Nothing copied
|
||||||
|
|
||||||
|
// Detect field name
|
||||||
|
let field = 'Unknown field';
|
||||||
|
|
||||||
|
if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') {
|
||||||
|
// Try multiple ways to identify the field
|
||||||
|
field =
|
||||||
|
target.getAttribute('aria-label') ||
|
||||||
|
target.getAttribute('name') ||
|
||||||
|
target.getAttribute('id') ||
|
||||||
|
(target as HTMLInputElement).type ||
|
||||||
|
target.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check parent labels
|
||||||
|
const label = target.closest('label') ||
|
||||||
|
document.querySelector(`label[for="${target.id}"]`);
|
||||||
|
if (label) {
|
||||||
|
field = label.textContent?.trim() || field;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for data-sensitive attribute
|
||||||
|
const sensitiveAttr = target.getAttribute('data-sensitive') ||
|
||||||
|
target.closest('[data-sensitive]')?.getAttribute('data-sensitive');
|
||||||
|
if (sensitiveAttr) {
|
||||||
|
field = sensitiveAttr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if it looks like sensitive data
|
||||||
|
const isSensitive = /mnemonic|seed|key|private|password|secret/i.test(
|
||||||
|
target.className + ' ' + field + ' ' + (target.getAttribute('placeholder') || '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSensitive && field === target.tagName.toLowerCase()) {
|
||||||
|
// Try to guess from placeholder
|
||||||
|
const placeholder = target.getAttribute('placeholder');
|
||||||
|
if (placeholder) {
|
||||||
|
field = placeholder.substring(0, 40) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents(prev => [
|
||||||
|
{ timestamp: new Date(), field, length },
|
||||||
|
...prev.slice(0, 9) // Keep last 10 events
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Auto-expand on first copy
|
||||||
|
if (events.length === 0) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('copy', handleCopy as EventListener);
|
||||||
|
return () => document.removeEventListener('copy', handleCopy as EventListener);
|
||||||
|
}, [events.length]);
|
||||||
|
|
||||||
|
const clearClipboard = async () => {
|
||||||
|
try {
|
||||||
|
// Actually clear the system clipboard
|
||||||
|
await navigator.clipboard.writeText('');
|
||||||
|
|
||||||
|
// Clear history
|
||||||
|
setEvents([]);
|
||||||
|
|
||||||
|
// Show success briefly
|
||||||
|
alert('✅ Clipboard cleared and history wiped');
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback for browsers that don't support clipboard API
|
||||||
|
const dummy = document.createElement('textarea');
|
||||||
|
dummy.value = '';
|
||||||
|
document.body.appendChild(dummy);
|
||||||
|
dummy.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(dummy);
|
||||||
|
|
||||||
|
setEvents([]);
|
||||||
|
alert('✅ History cleared (clipboard may require manual clearing)');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-24 right-4 z-50 max-w-sm">
|
||||||
|
<div className={`rounded-lg shadow-lg border-2 transition-all ${events.length > 0
|
||||||
|
? 'bg-orange-50 border-orange-400'
|
||||||
|
: 'bg-gray-50 border-gray-300'
|
||||||
|
}`}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 cursor-pointer flex items-center justify-between rounded-t-lg transition-colors ${events.length > 0 ? 'hover:bg-orange-100' : 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">📋</span>
|
||||||
|
<span className="font-semibold text-sm text-gray-700">Clipboard Activity</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{events.length > 0 && (
|
||||||
|
<span className="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full font-medium">
|
||||||
|
{events.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400 text-sm">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-4 border-t border-gray-300">
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div className="mb-3 bg-orange-100 border border-orange-300 rounded-md p-3 text-xs text-orange-900">
|
||||||
|
<strong>⚠️ Clipboard Warning:</strong> Copied data is accessible to other apps,
|
||||||
|
browser tabs, and extensions. Clear clipboard after use.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{events.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 mb-3 max-h-64 overflow-y-auto">
|
||||||
|
{events.map((event, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-orange-200 rounded-md p-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-orange-900 break-all">
|
||||||
|
{event.field}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-[10px] whitespace-nowrap">
|
||||||
|
{event.timestamp.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-[10px]">
|
||||||
|
Copied {event.length} character{event.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent collapse toggle
|
||||||
|
clearClipboard();
|
||||||
|
}}
|
||||||
|
className="w-full text-xs py-2 px-3 bg-orange-600 hover:bg-orange-700 text-white rounded-md font-medium transition-colors"
|
||||||
|
>
|
||||||
|
🗑️ Clear Clipboard & History
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="text-3xl mb-2">✅</div>
|
||||||
|
<p className="text-xs text-gray-500">No clipboard activity detected</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Upload } from 'lucide-react';
|
import { Upload } from 'lucide-react';
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
interface PgpKeyInputProps {
|
interface PgpKeyInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
||||||
@@ -17,21 +16,25 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
label,
|
label,
|
||||||
icon: Icon
|
icon: Icon,
|
||||||
|
readOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
if (readOnly) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
if (readOnly) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
if (readOnly) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
@@ -53,24 +56,27 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
|||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{Icon && <Icon size={14} />} {label}
|
{Icon && <Icon size={14} />} {label}
|
||||||
</span>
|
</span>
|
||||||
|
{!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-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
|
||||||
Drag & Drop .asc file
|
Drag & Drop .asc file
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
className={`relative transition-all duration-200 ${isDragging ? 'scale-[1.01]' : ''}`}
|
className={`relative transition-all duration-200 ${isDragging && !readOnly ? 'scale-[1.01]' : ''}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 ${isDragging ? 'border-blue-500 bg-blue-50' : 'border-slate-200'
|
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 ${isDragging && !readOnly ? 'border-blue-500 bg-blue-50' : 'border-slate-200'
|
||||||
}`}
|
}`}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
{isDragging && (
|
{isDragging && !readOnly && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-50/90 rounded-xl border-2 border-dashed border-blue-500 pointer-events-none z-10">
|
<div className="absolute inset-0 flex items-center justify-center bg-blue-50/90 rounded-xl border-2 border-dashed border-blue-500 pointer-events-none z-10">
|
||||||
<div className="text-blue-600 font-bold flex flex-col items-center animate-bounce">
|
<div className="text-blue-600 font-bold flex flex-col items-center animate-bounce">
|
||||||
<Upload size={24} />
|
<Upload size={24} />
|
||||||
|
|||||||
224
src/components/QRScanner.tsx
Normal file
224
src/components/QRScanner.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Camera, Upload, X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
|
||||||
|
import { Html5Qrcode } from 'html5-qrcode';
|
||||||
|
|
||||||
|
interface QRScannerProps {
|
||||||
|
onScanSuccess: (scannedText: string) => void;
|
||||||
|
onClose: () => 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>('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
setError('');
|
||||||
|
setScanMode('camera');
|
||||||
|
setScanning(true);
|
||||||
|
|
||||||
|
// Wait for DOM to render the #qr-reader div
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html5QrCode = new Html5Qrcode('qr-reader');
|
||||||
|
html5QrCodeRef.current = html5QrCode;
|
||||||
|
|
||||||
|
await html5QrCode.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
{
|
||||||
|
fps: 10,
|
||||||
|
qrbox: { width: 250, height: 250 },
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
},
|
||||||
|
(decodedText) => {
|
||||||
|
if (decodedText.startsWith('SEEDPGP1:')) {
|
||||||
|
setSuccess(true);
|
||||||
|
onScanSuccess(decodedText);
|
||||||
|
stopCamera();
|
||||||
|
} else {
|
||||||
|
setError('QR code found, but not a valid SEEDPGP1 frame');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Ignore frequent scanning errors
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Camera error:', err);
|
||||||
|
setError(`Camera failed: ${err.message || 'Permission denied or not available'}`);
|
||||||
|
setScanning(false);
|
||||||
|
setScanMode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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">
|
||||||
|
<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} />
|
||||||
|
</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">
|
||||||
|
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
{!scanMode && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={startCamera}
|
||||||
|
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<Camera size={20} />
|
||||||
|
Use Camera
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Upload size={20} />
|
||||||
|
Upload Image
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="pt-4 border-t border-slate-200">
|
||||||
|
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
|
||||||
|
<Info size={14} className="shrink-0 mt-0.5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
|
||||||
|
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Camera View */}
|
||||||
|
{scanMode === 'camera' && scanning && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div id="qr-reader" className="rounded-lg overflow-hidden border-2 border-slate-200"></div>
|
||||||
|
<button
|
||||||
|
onClick={stopCamera}
|
||||||
|
className="w-full py-3 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Stop Camera
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File Processing View */}
|
||||||
|
{scanMode === 'file' && scanning && (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-blue-600"></div>
|
||||||
|
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden div for file scanning */}
|
||||||
|
<div id="qr-reader-file" className="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
interface QrDisplayProps {
|
interface QrDisplayProps {
|
||||||
@@ -11,21 +12,61 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (value) {
|
||||||
QRCode.toDataURL(value, {
|
QRCode.toDataURL(value, {
|
||||||
errorCorrectionLevel: 'Q',
|
errorCorrectionLevel: 'M',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
width: 512,
|
width: 512,
|
||||||
margin: 2,
|
margin: 4,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then(setDataUrl)
|
.then(setDataUrl)
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [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 filename = `SeedPGP_${date}_${time}.png`;
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
if (!dataUrl) return null;
|
if (!dataUrl) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<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-64 h-64" />
|
<img
|
||||||
|
src={dataUrl}
|
||||||
|
alt="SeedPGP QR Code"
|
||||||
|
className="w-80 h-80"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
39
src/components/ReadOnly.tsx
Normal file
39
src/components/ReadOnly.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { WifiOff } from 'lucide-react';
|
||||||
|
|
||||||
|
type ReadOnlyProps = {
|
||||||
|
isReadOnly: boolean;
|
||||||
|
onToggle: (isReadOnly: boolean) => void;
|
||||||
|
buildHash: string;
|
||||||
|
appVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CSP_POLICY = `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none';`;
|
||||||
|
|
||||||
|
export function ReadOnly({ isReadOnly, onToggle, buildHash, appVersion }: ReadOnlyProps) {
|
||||||
|
return (
|
||||||
|
<div className="pt-3 border-t border-slate-300">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isReadOnly}
|
||||||
|
onChange={(e) => onToggle(e.target.checked)}
|
||||||
|
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||||
|
Read-only Mode
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{isReadOnly && (
|
||||||
|
<div className="mt-4 p-3 bg-slate-800 text-slate-200 rounded-lg text-xs space-y-2 animate-in fade-in">
|
||||||
|
<p className="font-bold flex items-center gap-2"><WifiOff size={14} /> Network & Persistence Disabled</p>
|
||||||
|
<div className="font-mono text-[10px] space-y-1">
|
||||||
|
<p><span className="font-semibold text-slate-400">Version:</span> {appVersion}</p>
|
||||||
|
<p><span className="font-semibold text-slate-400">Build:</span> {buildHash}</p>
|
||||||
|
<p className="pt-1 font-semibold text-slate-400">Content Security Policy:</p>
|
||||||
|
<p className="text-sky-300 break-words">{CSP_POLICY}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/SecurityWarnings.tsx
Normal file
81
src/components/SecurityWarnings.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export const SecurityWarnings = () => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 z-50 max-w-sm">
|
||||||
|
<div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-lg">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 cursor-pointer flex items-center justify-between hover:bg-yellow-100 transition-colors rounded-t-lg"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">⚠️</span>
|
||||||
|
<span className="font-semibold text-sm text-yellow-900">Security Limitations</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-yellow-600 text-sm">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-yellow-300 space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🧵"
|
||||||
|
title="JavaScript Strings are Immutable"
|
||||||
|
description="Strings cannot be overwritten in memory. Copies persist until garbage collection runs (timing unpredictable)."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🗑️"
|
||||||
|
title="No Guaranteed Memory Wiping"
|
||||||
|
description="JavaScript has no secure memory clearing. Sensitive data may linger in RAM until GC or browser restart."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="📋"
|
||||||
|
title="Clipboard Exposure"
|
||||||
|
description="Copied data is accessible to other tabs/apps. Browser extensions can read clipboard contents."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="💾"
|
||||||
|
title="Browser Storage Persistence"
|
||||||
|
description="localStorage survives browser restart. sessionStorage survives page refresh. Both readable by any script on this domain."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🔍"
|
||||||
|
title="DevTools Access"
|
||||||
|
description="All app state, memory, and storage visible in browser DevTools. Never use on untrusted devices."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🌐"
|
||||||
|
title="Network Risks (When Online)"
|
||||||
|
description="If hosted online: DNS, HTTPS, CDN, and browser can see usage patterns. Use offline/local for maximum security."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-yellow-300 text-xs text-yellow-800">
|
||||||
|
<strong>Recommendation:</strong> Use this tool on a dedicated offline device.
|
||||||
|
Clear browser data after each use. Never use on shared/public computers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Warning = ({ icon, title, description }: { icon: string; title: string; description: string }) => (
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<span className="text-base flex-shrink-0">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-yellow-900 mb-0.5">{title}</div>
|
||||||
|
<div className="text-yellow-800 leading-relaxed">{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
150
src/components/StorageIndicator.tsx
Normal file
150
src/components/StorageIndicator.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface StorageItem {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
size: number;
|
||||||
|
isSensitive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageIndicator = () => {
|
||||||
|
const [localItems, setLocalItems] = useState<StorageItem[]>([]);
|
||||||
|
const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password'];
|
||||||
|
|
||||||
|
const isSensitiveKey = (key: string): boolean => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
return SENSITIVE_PATTERNS.some(pattern => lowerKey.includes(pattern));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStorageItems = (storage: Storage): StorageItem[] => {
|
||||||
|
const items: StorageItem[] = [];
|
||||||
|
for (let i = 0; i < storage.length; i++) {
|
||||||
|
const key = storage.key(i);
|
||||||
|
if (key) {
|
||||||
|
const value = storage.getItem(key) || '';
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
value: value.substring(0, 50) + (value.length > 50 ? '...' : ''),
|
||||||
|
size: new Blob([value]).size,
|
||||||
|
isSensitive: isSensitiveKey(key)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => (b.isSensitive ? 1 : 0) - (a.isSensitive ? 1 : 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshStorage = () => {
|
||||||
|
setLocalItems(getStorageItems(localStorage));
|
||||||
|
setSessionItems(getStorageItems(sessionStorage));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshStorage();
|
||||||
|
const interval = setInterval(refreshStorage, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalItems = localItems.length + sessionItems.length;
|
||||||
|
const sensitiveCount = [...localItems, ...sessionItems].filter(i => i.isSensitive).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 max-w-md">
|
||||||
|
<div className={`bg-white rounded-lg shadow-lg border-2 ${sensitiveCount > 0 ? 'border-red-400' : 'border-gray-300'
|
||||||
|
} transition-all duration-200`}>
|
||||||
|
|
||||||
|
{/* Header Bar */}
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 rounded-t-lg cursor-pointer flex items-center justify-between ${sensitiveCount > 0 ? 'bg-red-50' : 'bg-gray-50'
|
||||||
|
} hover:opacity-90 transition-opacity`}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">🗄️</span>
|
||||||
|
<span className="font-semibold text-sm text-gray-700">Storage Monitor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{sensitiveCount > 0 && (
|
||||||
|
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full font-medium">
|
||||||
|
{sensitiveCount}⚠️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${totalItems > 0 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{totalItems === 0 ? '✓ Empty' : `${totalItems} item${totalItems !== 1 ? 's' : ''}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-sm">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-4 max-h-96 overflow-y-auto">
|
||||||
|
{sensitiveCount > 0 && (
|
||||||
|
<div className="mb-3 bg-yellow-50 border border-yellow-300 rounded-md p-3 text-xs">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-yellow-600 mt-0.5">⚠️</span>
|
||||||
|
<div className="text-yellow-800">
|
||||||
|
<strong>Security Notice:</strong> Sensitive data persists in browser storage
|
||||||
|
(survives refresh/restart). Clear manually if on shared device.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<StorageSection title="localStorage" items={localItems} icon="💾" />
|
||||||
|
<StorageSection title="sessionStorage" items={sessionItems} icon="⏱️" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalItems === 0 && (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<div className="text-4xl mb-2">✅</div>
|
||||||
|
<p className="text-sm text-gray-500">No data in browser storage</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StorageSection = ({ title, items, icon }: { title: string; items: StorageItem[]; icon: string }) => {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-gray-500 mb-2 flex items-center gap-1">
|
||||||
|
<span>{icon}</span>
|
||||||
|
<span className="uppercase">{title}</span>
|
||||||
|
<span className="ml-auto text-gray-400 font-normal">({items.length})</span>
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className={`text-xs rounded-md border p-2 ${item.isSensitive
|
||||||
|
? 'bg-red-50 border-red-300'
|
||||||
|
: 'bg-gray-50 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2 mb-1">
|
||||||
|
<span className={`font-mono font-semibold text-[11px] break-all ${item.isSensitive ? 'text-red-700' : 'text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{item.isSensitive && '🔴 '}{item.key}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-[10px] whitespace-nowrap">{item.size}B</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 font-mono text-[10px] break-all leading-relaxed opacity-70">
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,12 @@ import { base45Encode, base45Decode } from "./base45";
|
|||||||
import { crc16CcittFalse } from "./crc16";
|
import { crc16CcittFalse } from "./crc16";
|
||||||
import type { SeedPgpPlaintext, ParsedSeedPgpFrame } from "./types";
|
import type { SeedPgpPlaintext, ParsedSeedPgpFrame } from "./types";
|
||||||
|
|
||||||
|
// Configure OpenPGP.js (disable warnings)
|
||||||
|
openpgp.config.showComment = false;
|
||||||
|
openpgp.config.showVersion = false;
|
||||||
|
openpgp.config.allowUnauthenticatedMessages = true; // Suppress AES warning
|
||||||
|
openpgp.config.allowUnauthenticatedStream = true; // Suppress stream warning
|
||||||
|
|
||||||
function nonEmptyTrimmed(s?: string): string | undefined {
|
function nonEmptyTrimmed(s?: string): string | undefined {
|
||||||
if (!s) return undefined;
|
if (!s) return undefined;
|
||||||
const t = s.trim();
|
const t = s.trim();
|
||||||
|
|||||||
205
src/lib/sessionCrypto.ts
Normal file
205
src/lib/sessionCrypto.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* @file Ephemeral, per-session, in-memory encryption using Web Crypto API.
|
||||||
|
*
|
||||||
|
* This module manages a single, non-exportable AES-GCM key for a user's session.
|
||||||
|
* It's designed to encrypt sensitive data (like a mnemonic) before it's placed
|
||||||
|
* into React state, mitigating the risk of plaintext data in memory snapshots.
|
||||||
|
* The key is destroyed when the user navigates away or the session ends.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Helper functions for encoding ---
|
||||||
|
|
||||||
|
function base64ToBytes(base64: string): Uint8Array {
|
||||||
|
const binString = atob(base64);
|
||||||
|
return Uint8Array.from(binString, (m) => m.codePointAt(0)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
const binString = Array.from(bytes, (byte) =>
|
||||||
|
String.fromCodePoint(byte),
|
||||||
|
).join("");
|
||||||
|
return btoa(binString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Module-level state ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the session's AES-GCM key. This variable is not exported and is
|
||||||
|
* only accessible through the functions in this module.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
let sessionKey: CryptoKey | null = null;
|
||||||
|
const KEY_ALGORITHM = 'AES-GCM';
|
||||||
|
const KEY_LENGTH = 256;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object containing encrypted data and necessary metadata for decryption.
|
||||||
|
*/
|
||||||
|
export interface EncryptedBlob {
|
||||||
|
v: 1;
|
||||||
|
/**
|
||||||
|
* The algorithm used. This is metadata; the actual Web Crypto API call
|
||||||
|
* uses `{ name: "AES-GCM", length: 256 }`.
|
||||||
|
*/
|
||||||
|
alg: 'A256GCM';
|
||||||
|
iv_b64: string; // Initialization Vector (base64)
|
||||||
|
ct_b64: string; // Ciphertext (base64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Core API Functions ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates and stores a session-level AES-GCM 256-bit key.
|
||||||
|
* The key is non-exportable and is held in a private module-level variable.
|
||||||
|
* If a key already exists, the existing key is returned, making the function idempotent.
|
||||||
|
* This function must be called before any encryption or decryption can occur.
|
||||||
|
* @returns A promise that resolves to the generated or existing CryptoKey.
|
||||||
|
*/
|
||||||
|
export async function getSessionKey(): Promise<CryptoKey> {
|
||||||
|
if (sessionKey) {
|
||||||
|
return sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: KEY_ALGORITHM,
|
||||||
|
length: KEY_LENGTH,
|
||||||
|
},
|
||||||
|
false, // non-exportable
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
sessionKey = key;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a JSON-serializable object using the current session key.
|
||||||
|
* @param data The object to encrypt. Must be JSON-serializable.
|
||||||
|
* @returns A promise that resolves to an EncryptedBlob.
|
||||||
|
*/
|
||||||
|
export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
|
||||||
|
if (!sessionKey) {
|
||||||
|
throw new Error('Session key not initialized. Call getSessionKey() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV is recommended for AES-GCM
|
||||||
|
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
||||||
|
|
||||||
|
const ciphertext = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: KEY_ALGORITHM,
|
||||||
|
iv: new Uint8Array(iv),
|
||||||
|
},
|
||||||
|
sessionKey,
|
||||||
|
plaintext,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
alg: 'A256GCM',
|
||||||
|
iv_b64: bytesToBase64(iv),
|
||||||
|
ct_b64: bytesToBase64(new Uint8Array(ciphertext)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts an EncryptedBlob back into its original object form.
|
||||||
|
* @param blob The EncryptedBlob to decrypt.
|
||||||
|
* @returns A promise that resolves to the original decrypted object.
|
||||||
|
*/
|
||||||
|
export async function decryptBlobToJson<T>(blob: EncryptedBlob): Promise<T> {
|
||||||
|
if (!sessionKey) {
|
||||||
|
throw new Error('Session key not initialized or has been destroyed.');
|
||||||
|
}
|
||||||
|
if (blob.v !== 1 || blob.alg !== 'A256GCM') {
|
||||||
|
throw new Error('Invalid or unsupported encrypted blob format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = base64ToBytes(blob.iv_b64);
|
||||||
|
const ciphertext = base64ToBytes(blob.ct_b64);
|
||||||
|
|
||||||
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: KEY_ALGORITHM,
|
||||||
|
iv: new Uint8Array(iv),
|
||||||
|
},
|
||||||
|
sessionKey,
|
||||||
|
new Uint8Array(ciphertext),
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonString = new TextDecoder().decode(decrypted);
|
||||||
|
return JSON.parse(jsonString) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the session key reference, making it unavailable for future
|
||||||
|
* operations and allowing it to be garbage collected.
|
||||||
|
*/
|
||||||
|
export function destroySessionKey(): void {
|
||||||
|
sessionKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A standalone test function that can be run in the browser console
|
||||||
|
* to verify the complete encryption and decryption lifecycle.
|
||||||
|
*
|
||||||
|
* To use:
|
||||||
|
* 1. Copy this entire function into the browser's developer console.
|
||||||
|
* 2. Run it by typing: `await runSessionCryptoTest()`
|
||||||
|
* 3. Check the console for logs.
|
||||||
|
*/
|
||||||
|
export async function runSessionCryptoTest(): Promise<void> {
|
||||||
|
console.log('--- Running Session Crypto Test ---');
|
||||||
|
try {
|
||||||
|
// 1. Destroy any old key
|
||||||
|
destroySessionKey();
|
||||||
|
console.log('Old key destroyed (if any).');
|
||||||
|
|
||||||
|
// 2. Generate a new key
|
||||||
|
await getSessionKey();
|
||||||
|
console.log('New session key generated.');
|
||||||
|
|
||||||
|
// 3. Define a secret object
|
||||||
|
const originalObject = {
|
||||||
|
mnemonic: 'fee table visa input phrase lake buffalo vague merit million mesh blend',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
console.log('Original object:', originalObject);
|
||||||
|
|
||||||
|
// 4. Encrypt the object
|
||||||
|
const encrypted = await encryptJsonToBlob(originalObject);
|
||||||
|
console.log('Encrypted blob:', encrypted);
|
||||||
|
if (typeof encrypted.ct_b64 !== 'string' || encrypted.ct_b64.length < 20) {
|
||||||
|
throw new Error('Encryption failed: ciphertext looks invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Decrypt the object
|
||||||
|
const decrypted = await decryptBlobToJson(encrypted);
|
||||||
|
console.log('Decrypted object:', decrypted);
|
||||||
|
|
||||||
|
// 6. Verify integrity
|
||||||
|
if (JSON.stringify(originalObject) !== JSON.stringify(decrypted)) {
|
||||||
|
throw new Error('Verification failed: Decrypted data does not match original data.');
|
||||||
|
}
|
||||||
|
console.log('%c✅ Success: Data integrity verified.', 'color: green; font-weight: bold;');
|
||||||
|
|
||||||
|
// 7. Test key destruction
|
||||||
|
destroySessionKey();
|
||||||
|
console.log('Session key destroyed.');
|
||||||
|
try {
|
||||||
|
await decryptBlobToJson(encrypted);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('As expected, decryption failed after key destruction:', (e as Error).message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('%c❌ Test Failed:', 'color: red; font-weight: bold;', error);
|
||||||
|
} finally {
|
||||||
|
console.log('--- Test Complete ---');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For convenience, attach the test runner to the window object.
|
||||||
|
// This is for development/testing only and can be removed in production.
|
||||||
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
|
(window as any).runSessionCryptoTest = runSessionCryptoTest;
|
||||||
|
}
|
||||||
24
src/main.tsx
24
src/main.tsx
@@ -1,8 +1,32 @@
|
|||||||
|
// Suppress OpenPGP.js AES cipher warnings
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
console.warn = (...args: any[]) => {
|
||||||
|
const msg = args[0]?.toString() || '';
|
||||||
|
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalWarn.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args: any[]) => {
|
||||||
|
const msg = args[0]?.toString() || '';
|
||||||
|
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalError.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
await import('./lib/sessionCrypto');
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
10
src/vite-env.d.ts
vendored
Normal file
10
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Allow importing CSS files
|
||||||
|
declare module '*.css' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __BUILD_HASH__: string;
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
// Read version from package.json
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
|
||||||
|
const appVersion = packageJson.version
|
||||||
|
|
||||||
|
// Get git commit hash
|
||||||
|
const gitHash = execSync('git rev-parse --short HEAD').toString().trim()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: process.env.CF_PAGES ? '/' : '/seedpgp-web-app/',
|
||||||
|
publicDir: 'public', // ← Explicitly set (should be default)
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: false,
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'__APP_VERSION__': JSON.stringify(appVersion),
|
||||||
|
'__BUILD_HASH__': JSON.stringify(gitHash),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user