mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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! 🚀📋
|
||||
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
|
||||
- [@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
|
||||
## Features
|
||||
|
||||
## 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
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
# Run tests
|
||||
bun test
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// 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...
|
||||
},
|
||||
},
|
||||
])
|
||||
# Start development server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
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
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
### Encrypt a Mnemonic
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```typescript
|
||||
import { encryptToSeedPgp, buildPlaintext } from "./lib/seedpgp";
|
||||
|
||||
const mnemonic = "legal winner thank year wave sausage worth useful legal winner thank yellow";
|
||||
const plaintext = buildPlaintext(mnemonic, false); // false = no BIP39 passphrase used
|
||||
|
||||
const result = await encryptToSeedPgp({
|
||||
plaintext,
|
||||
publicKeyArmored: yourPgpPublicKey,
|
||||
});
|
||||
|
||||
console.log(result.framed); // SEEDPGP1:0:ABCD:BASE45DATA...
|
||||
console.log(result.recipientFingerprint); // Key fingerprint for verification
|
||||
```
|
||||
|
||||
### Decrypt a SeedPGP Frame
|
||||
|
||||
```typescript
|
||||
import { decryptSeedPgp } from "./lib/seedpgp";
|
||||
|
||||
const decrypted = await decryptSeedPgp({
|
||||
frameText: "SEEDPGP1:0:ABCD:BASE45DATA...",
|
||||
privateKeyArmored: yourPrivateKey,
|
||||
privateKeyPassphrase: "your-key-password",
|
||||
});
|
||||
|
||||
console.log(decrypted.w); // Recovered mnemonic
|
||||
console.log(decrypted.pp); // BIP39 passphrase indicator (0 or 1)
|
||||
```
|
||||
|
||||
## Deployment to GitHub Pages (FREE)
|
||||
|
||||
This project uses a two-repository setup to keep source code private while hosting the app for free.
|
||||
|
||||
### One-Time Setup
|
||||
|
||||
#### 1. Create Public Deployment Repo
|
||||
|
||||
Go to https://github.com/new and create:
|
||||
- **Name**: `seedpgp-web-app` (or any name you prefer)
|
||||
- **Visibility**: **Public**
|
||||
- **Don't** initialize with README, .gitignore, or license
|
||||
|
||||
#### 2. Configure Vite Base Path
|
||||
|
||||
Edit `vite.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/seedpgp-web-app/', // Match your public repo name
|
||||
})
|
||||
```
|
||||
|
||||
#### 3. Build and Deploy
|
||||
|
||||
```bash
|
||||
# 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",
|
||||
"dependencies": {
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"lucide-react": "^0.462.0",
|
||||
"openpgp": "^6.3.0",
|
||||
"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/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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -297,6 +298,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"lucide-react": "^0.462.0",
|
||||
"openpgp": "^6.3.0",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
48
public/README.md
Normal file
48
public/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# SeedPGP Web App
|
||||
|
||||
**Secure BIP39 mnemonic backup tool using PGP encryption**
|
||||
|
||||
🔗 **Live App**: https://kccleoc.github.io/seedpgp-web-app/
|
||||
|
||||
## About
|
||||
|
||||
This is a client-side web application for encrypting cryptocurrency seed phrases (BIP39 mnemonics) using OpenPGP encryption and Base45 encoding for QR code generation.
|
||||
|
||||
### Features
|
||||
|
||||
- 🔐 PGP encryption with cv25519 (Curve25519)
|
||||
- 📱 QR code-ready output (Base45 + CRC16)
|
||||
- ✅ Supports 12/18/24-word BIP39 mnemonics
|
||||
- 🔒 All encryption happens in your browser (client-side only)
|
||||
|
||||
## Security Notice
|
||||
|
||||
⚠️ **Your private keys and seed phrases never leave your browser**
|
||||
|
||||
- This is a static web app with no backend server
|
||||
- All cryptographic operations run locally in your browser
|
||||
- No data is transmitted to any server
|
||||
- Always verify you're on the correct URL before use
|
||||
|
||||
### For Maximum Security
|
||||
|
||||
For production use with real funds:
|
||||
- Download and run locally
|
||||
- Or use a self-hosted version
|
||||
- Source code: https://github.com/kccleoc/seedpgp-web
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Encrypt**: Enter your mnemonic + PGP public key → Get QR code
|
||||
2. **Decrypt**: Scan QR code + provide private key → Recover mnemonic
|
||||
|
||||
## Version
|
||||
|
||||
Current deployment: **v1.1.0**
|
||||
|
||||
Last updated: 2026-01-28
|
||||
|
||||
---
|
||||
|
||||
Built with TypeScript, React, Vite, and OpenPGP.js v6
|
||||
|
||||
31
scripts/deploy.sh
Executable file
31
scripts/deploy.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION=$1
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: ./scripts/deploy.sh v1.2.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔨 Building $VERSION..."
|
||||
bun run build
|
||||
|
||||
echo "📄 Adding README..."
|
||||
cp public/README.md dist/README.md
|
||||
|
||||
echo "📦 Deploying to GitHub Pages..."
|
||||
cd dist
|
||||
git add .
|
||||
git commit -m "Deploy $VERSION" || echo "No changes to commit"
|
||||
git push
|
||||
|
||||
cd ..
|
||||
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
|
||||
echo "📖 Repo: https://github.com/kccleoc/seedpgp-web-app"
|
||||
echo "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
|
||||
echo "./scripts/deploy.sh v1.2.0"
|
||||
echo "git tag v1.2.0"
|
||||
echo "git push --tags"
|
||||
|
||||
|
||||
569
src/App.tsx
569
src/App.tsx
@@ -14,17 +14,22 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { PgpKeyInput } from './components/PgpKeyInput';
|
||||
import { QrDisplay } from './components/QrDisplay';
|
||||
import QRScanner from './components/QRScanner';
|
||||
import { validateBip39Mnemonic } from './lib/bip39';
|
||||
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
|
||||
import type { SeedPgpPlaintext } from './lib/types';
|
||||
import * as openpgp from 'openpgp';
|
||||
|
||||
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
|
||||
const [mnemonic, setMnemonic] = useState('');
|
||||
const [messagePassword, setMessagePassword] = useState('');
|
||||
const [pgpKeyInput, setPgpKeyInput] = useState('');
|
||||
const [backupMessagePassword, setBackupMessagePassword] = useState('');
|
||||
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
|
||||
|
||||
const [publicKeyInput, setPublicKeyInput] = useState('');
|
||||
const [privateKeyInput, setPrivateKeyInput] = useState('');
|
||||
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
|
||||
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
|
||||
const [qrPayload, setQrPayload] = useState('');
|
||||
@@ -35,6 +40,7 @@ function App() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showMnemonic, setShowMnemonic] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showQRScanner, setShowQRScanner] = useState(false);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
@@ -42,7 +48,6 @@ function App() {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Fallback for environments where Clipboard API is blocked
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
@@ -57,7 +62,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleBackup = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@@ -74,8 +78,8 @@ function App() {
|
||||
|
||||
const result = await encryptToSeedPgp({
|
||||
plaintext,
|
||||
publicKeyArmored: pgpKeyInput || undefined,
|
||||
messagePassword: messagePassword || undefined,
|
||||
publicKeyArmored: publicKeyInput || undefined,
|
||||
messagePassword: backupMessagePassword || undefined, // Changed
|
||||
});
|
||||
|
||||
setQrPayload(result.framed);
|
||||
@@ -97,11 +101,12 @@ function App() {
|
||||
try {
|
||||
const result = await decryptSeedPgp({
|
||||
frameText: restoreInput,
|
||||
privateKeyArmored: pgpKeyInput || undefined,
|
||||
privateKeyArmored: privateKeyInput || undefined,
|
||||
privateKeyPassphrase: privateKeyPassphrase || undefined,
|
||||
messagePassword: messagePassword || undefined,
|
||||
messagePassword: restoreMessagePassword || undefined, // Changed
|
||||
});
|
||||
|
||||
|
||||
setRestoredData(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Decryption failed');
|
||||
@@ -110,296 +115,322 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
|
||||
<>
|
||||
<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">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-600 rounded-lg shadow-lg">
|
||||
<Shield size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.1</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('backup');
|
||||
setError('');
|
||||
setQrPayload('');
|
||||
setRestoredData(null);
|
||||
}}
|
||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
|
||||
? 'bg-white text-slate-900 shadow-lg'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
Backup
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('restore');
|
||||
setError('');
|
||||
setQrPayload('');
|
||||
setRestoredData(null);
|
||||
}}
|
||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
|
||||
? 'bg-white text-slate-900 shadow-lg'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border-l-4 border-red-500 rounded-r-xl flex gap-3 text-red-800 text-sm items-start animate-in slide-in-from-top-2">
|
||||
<AlertCircle className="shrink-0 mt-0.5" size={20} />
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-600 rounded-lg shadow-lg">
|
||||
<Shield size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold mb-1">Error</p>
|
||||
<p className="whitespace-pre-wrap">{error}</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.2</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Banner */}
|
||||
{recipientFpr && activeTab === 'backup' && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3 text-blue-800 text-xs animate-in fade-in">
|
||||
<Info size={16} className="shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
|
||||
</div>
|
||||
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('backup');
|
||||
setError('');
|
||||
setQrPayload('');
|
||||
setRestoredData(null);
|
||||
}}
|
||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
|
||||
? 'bg-white text-slate-900 shadow-lg'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
Backup
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('restore');
|
||||
setError('');
|
||||
setQrPayload('');
|
||||
setRestoredData(null);
|
||||
}}
|
||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
|
||||
? 'bg-white text-slate-900 shadow-lg'
|
||||
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{activeTab === 'backup' ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
|
||||
<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"
|
||||
placeholder="Enter your 12 or 24 word seed phrase..."
|
||||
value={mnemonic}
|
||||
onChange={(e) => setMnemonic(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border-l-4 border-red-500 rounded-r-xl flex gap-3 text-red-800 text-sm items-start animate-in slide-in-from-top-2">
|
||||
<AlertCircle className="shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-bold mb-1">Error</p>
|
||||
<p className="whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PgpKeyInput
|
||||
label="PGP Public Key (Optional)"
|
||||
icon={FileKey}
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- Paste or drag & drop your public key..."
|
||||
value={pgpKeyInput}
|
||||
onChange={setPgpKeyInput}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
|
||||
<textarea
|
||||
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
||||
placeholder="SEEDPGP1:0:ABCD:..."
|
||||
value={restoreInput}
|
||||
onChange={(e) => setRestoreInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Info Banner */}
|
||||
{recipientFpr && activeTab === 'backup' && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3 text-blue-800 text-xs animate-in fade-in">
|
||||
<Info size={16} className="shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PgpKeyInput
|
||||
label="PGP Private Key (Optional)"
|
||||
icon={FileKey}
|
||||
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
||||
value={pgpKeyInput}
|
||||
onChange={setPgpKeyInput}
|
||||
/>
|
||||
|
||||
{pgpKeyInput && (
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{activeTab === 'backup' ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||
<input
|
||||
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"
|
||||
placeholder="Unlock private key..."
|
||||
value={privateKeyPassphrase}
|
||||
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
|
||||
/>
|
||||
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
|
||||
<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"
|
||||
placeholder="Enter your 12 or 24 word seed phrase..."
|
||||
value={mnemonic}
|
||||
onChange={(e) => setMnemonic(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PgpKeyInput
|
||||
label="PGP Public Key (Optional)"
|
||||
icon={FileKey}
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- Paste or drag & drop your public key..."
|
||||
value={publicKeyInput}
|
||||
onChange={setPublicKeyInput}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowQRScanner(true)}
|
||||
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"
|
||||
>
|
||||
<QrCode size={18} />
|
||||
Scan QR Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
|
||||
<textarea
|
||||
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
||||
placeholder="SEEDPGP1:0:ABCD:..."
|
||||
value={restoreInput}
|
||||
onChange={(e) => setRestoreInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PgpKeyInput
|
||||
label="PGP Private Key (Optional)"
|
||||
icon={FileKey}
|
||||
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
||||
value={privateKeyInput}
|
||||
onChange={setPrivateKeyInput}
|
||||
/>
|
||||
|
||||
{privateKeyInput && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||
<input
|
||||
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"
|
||||
placeholder="Unlock private key..."
|
||||
value={privateKeyPassphrase}
|
||||
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Panel */}
|
||||
<div className="space-y-6">
|
||||
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-wider flex items-center gap-2">
|
||||
<Lock size={14} /> Security Options
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||
<input
|
||||
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"
|
||||
placeholder="Optional password..."
|
||||
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
|
||||
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
|
||||
</div>
|
||||
|
||||
|
||||
{activeTab === 'backup' && (
|
||||
<div className="pt-3 border-t border-slate-300">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasBip39Passphrase}
|
||||
onChange={(e) => setHasBip39Passphrase(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">
|
||||
BIP39 25th word active
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Panel */}
|
||||
<div className="space-y-6">
|
||||
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-wider flex items-center gap-2">
|
||||
<Lock size={14} /> Security Options
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||
<input
|
||||
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"
|
||||
placeholder="Optional password..."
|
||||
value={messagePassword}
|
||||
onChange={(e) => setMessagePassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
|
||||
</div>
|
||||
|
||||
{activeTab === 'backup' && (
|
||||
<div className="pt-3 border-t border-slate-300">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasBip39Passphrase}
|
||||
onChange={(e) => setHasBip39Passphrase(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">
|
||||
BIP39 25th word active
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* Action Button */}
|
||||
{activeTab === 'backup' ? (
|
||||
<button
|
||||
onClick={handleBackup}
|
||||
disabled={!mnemonic || loading}
|
||||
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 ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<QrCode size={20} />
|
||||
)}
|
||||
{loading ? 'Generating...' : 'Generate QR Backup'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRestore}
|
||||
disabled={!restoreInput || loading}
|
||||
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 ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<Unlock size={20} />
|
||||
)}
|
||||
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
{activeTab === 'backup' ? (
|
||||
<button
|
||||
onClick={handleBackup}
|
||||
disabled={!mnemonic || loading}
|
||||
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 ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<QrCode size={20} />
|
||||
)}
|
||||
{loading ? 'Generating...' : 'Generate QR Backup'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRestore}
|
||||
disabled={!restoreInput || loading}
|
||||
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 ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<Unlock size={20} />
|
||||
)}
|
||||
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Output */}
|
||||
{qrPayload && activeTab === 'backup' && (
|
||||
<div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div className="flex justify-center">
|
||||
<QrDisplay value={qrPayload} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
Raw payload (copy for backup)
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(qrPayload)}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-900 text-white text-xs font-semibold hover:bg-black transition-colors"
|
||||
>
|
||||
{copied ? <CheckCircle2 size={14} /> : <QrCode size={14} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
{/* QR Output */}
|
||||
{qrPayload && activeTab === 'backup' && (
|
||||
<div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div className="flex justify-center">
|
||||
<QrDisplay value={qrPayload} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
Raw payload (copy for backup)
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={qrPayload}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="w-full h-28 p-3 bg-slate-900 rounded-xl font-mono text-[10px] text-green-400 border border-slate-700 shadow-inner leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Tip: click the box to select all, or use Copy.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(qrPayload)}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-900 text-white text-xs font-semibold hover:bg-black transition-colors"
|
||||
>
|
||||
{copied ? <CheckCircle2 size={14} /> : <QrCode size={14} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restored Mnemonic */}
|
||||
{restoredData && activeTab === 'restore' && (
|
||||
<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="flex items-center justify-between mb-4">
|
||||
<span className="font-bold text-green-700 flex items-center gap-2 text-lg">
|
||||
<CheckCircle2 size={22} /> Mnemonic Recovered
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowMnemonic(!showMnemonic)}
|
||||
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
|
||||
>
|
||||
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />}
|
||||
</button>
|
||||
</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'
|
||||
}`}>
|
||||
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
|
||||
{restoredData.w}
|
||||
<textarea
|
||||
readOnly
|
||||
value={qrPayload}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="w-full h-28 p-3 bg-slate-900 rounded-xl font-mono text-[10px] text-green-400 border border-slate-700 shadow-inner leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Tip: click the box to select all, or use Copy.
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Restored Mnemonic */}
|
||||
{restoredData && activeTab === 'restore' && (
|
||||
<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="flex items-center justify-between mb-4">
|
||||
<span className="font-bold text-green-700 flex items-center gap-2 text-lg">
|
||||
<CheckCircle2 size={22} /> Mnemonic Recovered
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowMnemonic(!showMnemonic)}
|
||||
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
|
||||
>
|
||||
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />}
|
||||
</button>
|
||||
</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'
|
||||
}`}>
|
||||
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
|
||||
{restoredData.w}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-slate-500">
|
||||
<p>SeedPGP v1.2 • 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<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 className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* QR Scanner Modal */}
|
||||
{showQRScanner && (
|
||||
<QRScanner
|
||||
onScanSuccess={(scannedText) => {
|
||||
setRestoreInput(scannedText);
|
||||
setShowQRScanner(false);
|
||||
setError('');
|
||||
}}
|
||||
onClose={() => setShowQRScanner(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
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 { Download } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface QrDisplayProps {
|
||||
@@ -11,21 +12,61 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
QRCode.toDataURL(value, {
|
||||
errorCorrectionLevel: 'Q',
|
||||
errorCorrectionLevel: 'M',
|
||||
type: 'image/png',
|
||||
width: 512,
|
||||
margin: 2,
|
||||
margin: 4,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
.then(setDataUrl)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-200">
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt="SeedPGP QR Code"
|
||||
className="w-80 h-80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
8
src/vite-env.d.ts
vendored
Normal file
8
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Allow importing CSS files
|
||||
declare module '*.css' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
@@ -3,4 +3,16 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/seedpgp-web-app/', // Match your repo name
|
||||
build: {
|
||||
chunkSizeWarningLimit: 600, // Suppress warning
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'openpgp': ['openpgp'], // Separate chunk for PGP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user