23 Commits

Author SHA1 Message Date
LC mac
e4516f3d19 chore: bump version to 1.4.0 2026-01-30 00:35:00 +08:00
LC mac
4b5bd80be6 feat(v1.3.0): ephemeral session-key encryption + cleanup
- Update version to 1.3.0
- Remove debug console logs
- Session-key encryption working in production
- Mnemonic auto-clears after QR generation
- Lock/Clear functionality verified
2026-01-30 00:08:43 +08:00
LC mac
8124375537 debug: add console logs to sessionCrypto for troubleshooting 2026-01-30 00:01:02 +08:00
LC mac
2107dab501 feat(v1.3.0): add ephemeral session-key encryption for sensitive state
- Add src/lib/sessionCrypto.ts with AES-GCM-256 non-exportable session keys
- Integrate into Backup flow: auto-clear plaintext mnemonic after QR generation
- Add Lock/Clear button to destroy session key and clear all state
- Add cleanup useEffect on component unmount
- Add comprehensive GEMINI.md for AI agent onboarding
- Fix TypeScript strict mode errors and unused imports

Tested:
- Session-key encryption working (mnemonic clears after QR gen)
- Lock/Clear functionality verified
- No plaintext secrets in localStorage/sessionStorage
- Production build successful
2026-01-29 23:48:21 +08:00
LC mac
0f397859e6 feat(v1.3.0): add ephemeral session-key encryption for sensitive state
- Add src/lib/sessionCrypto.ts with AES-GCM-256 session keys
- Integrate into Backup flow: auto-clear plaintext mnemonic after QR gen
- Add Lock/Clear button to destroy key and clear all state
- Add cleanup on component unmount
- Fix unused imports and TypeScript strict mode errors
2026-01-29 23:35:08 +08:00
LC mac
d4919f3d93 docs: add comprehensive GEMINI.md for AI agent onboarding (v1.3.0) 2026-01-29 23:34:23 +08:00
LC mac
c1b1f566df Fix: Resolve type incompatibility errors in sessionCrypto.ts 2026-01-29 23:24:57 +08:00
LC mac
6bbfe665cd bug fix app.tsx 2026-01-29 23:18:29 +08:00
LC mac
8e656749fe feat(v1.3.0): add ephemeral session-key encryption for sensitive state 2026-01-29 23:14:42 +08:00
LC mac
5a4018dcfe feat(v1.2.1): add security monitoring components
- Add Storage Indicator (browser localStorage/sessionStorage monitor)
- Add Security Warnings (educational panel on JS limitations)
- Add Clipboard Tracker (copy event detection with clear function)
- Add data-sensitive attributes to sensitive fields

Security Features:
- Real-time storage monitoring with sensitive data highlighting
- Clipboard activity tracking with character count
- System clipboard clearing functionality
- Collapsible floating widgets (non-intrusive)
- Auto-refresh storage display every 2s
- Educational warnings about GC, string immutability, etc.

UI/UX:
- Floating widgets: Storage (bottom-right), Warnings (bottom-left), Clipboard (bottom-right stacked)
- Color-coded alerts (red=sensitive, orange=activity, yellow=warnings)
- Responsive and clean design with TailwindCSS
2026-01-29 01:20:50 +08:00
LC mac
94764248d0 docs: update public README for v1.2.0 2026-01-29 00:36:00 +08:00
LC mac
a4a737540a feat(v1.2.0): add QR scanner with camera/upload support
- Add QRScanner component with camera and image upload
- Add QR code download button with auto-naming (SeedPGP_DATE_TIME.png)
- Split state for backup/restore (separate public/private keys and passwords)
- Improve QR generation settings (margin: 4, errorCorrection: M)
- Fix Safari camera permissions and Continuity Camera support
- Add React timing fix for Html5Qrcode initialization
- Configure Vite for GitHub Pages deployment
- Update deploy script for dist as public repo
- Update public README for v1.2.0 features

Features:
- Camera scanning with live preview and Continuity Camera
- Image file upload scanning
- Automatic SEEDPGP1 validation
- 512x512px high-quality QR generation with download
- User-friendly error messages
2026-01-29 00:33:09 +08:00
LC mac
b7da81c5d4 chore: configure vite for GitHub Pages deployment 2026-01-29 00:21:26 +08:00
LC mac
c55390228b feat(v1.2.0): add QR scanner with camera/upload support
- Add QRScanner component with camera and image upload
- Add QR code download button with auto-naming (SeedPGP_DATE_TIME.png)
- Split state for backup/restore (separate public/private keys and passwords)
- Improve QR generation settings (margin: 4, errorCorrection: M)
- Fix Safari camera permissions and Continuity Camera support
- Add React timing fix for Html5Qrcode initialization

Features:
- Camera scanning with live preview
- Image file upload scanning
- Automatic SEEDPGP1 validation
- User-friendly error messages
- 512x512px high-quality QR generation
2026-01-28 23:54:02 +08:00
LC mac
3285bc383e update deploy script - add note 2026-01-28 12:54:52 +08:00
LC mac
307a97516a docs: add deployment repo README 2026-01-28 12:49:09 +08:00
LC mac
b1d3406cc7 docs: add GitHub Pages deployment guide 2026-01-28 12:36:13 +08:00
a96b39bb62 Update README.md
small format change
2026-01-28 12:33:24 +08:00
7d49f9cffd Update README.md
docs: add GitHub Pages deployment guide
2026-01-28 12:32:26 +08:00
LC mac
24f50014af update vite.config.ts 2026-01-28 12:28:10 +08:00
LC mac
b2be6d381c add build file vite-env.d.ts 2026-01-28 12:25:18 +08:00
34633af539 Update and rename _GPT-Suggestion.md to DEVELOPMENT.md 2026-01-28 02:56:26 +08:00
LC mac
2959b44e39 docs: add comprehensive README for v1.1.0 2026-01-28 02:47:37 +08:00
21 changed files with 2714 additions and 487 deletions

291
DEVELOPMENT.md Normal file
View 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! 🚀📋

377
GEMINI.md Normal file
View File

@@ -0,0 +1,377 @@
# 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
```
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 - Session-Key Encryption (Current)
- **Ephemeral encryption**: AES-GCM-256 session key (non-exportable) encrypts sensitive state
- **Auto-clear**: Plaintext mnemonic cleared from UI immediately after QR generation
- **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
1. **Private repo** (`seedpgp-web`): Source code, development
2. **Public repo** (`seedpgp-web-app`): Built files for GitHub Pages
3. **Deploy script** (`scripts/deploy.sh`): Builds + copies to dist/ + pushes to public repo
### 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.3.0
### Recent Changes (2026-01-29)
- ✅ Added `src/lib/sessionCrypto.ts` with ephemeral AES-GCM session keys
- ✅ Integrated into Backup flow: plaintext mnemonic auto-cleared after QR generation
- ✅ Added Lock/Clear button to destroy session key and clear all state
- ✅ Added cleanup on component unmount
### 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
View File

@@ -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
``` ```

View File

@@ -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! 🔐✨

View File

@@ -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=="],
} }
} }

View File

@@ -1,14 +1,16 @@
{ {
"name": "seedpgp-web", "name": "seedpgp-web",
"private": true, "private": true,
"version": "1.1.0", "version": "1.3.0",
"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
View 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

35
scripts/deploy.sh Executable file
View 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"

View File

@@ -1,405 +1,534 @@
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';
function App() { import { ClipboardTracker } from './components/ClipboardTracker';
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup'); import { ReadOnly } from './components/ReadOnly';
const [mnemonic, setMnemonic] = useState(''); import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
const [messagePassword, setMessagePassword] = useState('');
const [pgpKeyInput, setPgpKeyInput] = useState(''); console.log("OpenPGP.js version:", openpgp.config.versionString);
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false); function App() {
const [qrPayload, setQrPayload] = useState(''); const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
const [recipientFpr, setRecipientFpr] = useState(''); const [mnemonic, setMnemonic] = useState('');
const [restoreInput, setRestoreInput] = useState(''); const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoredData, setRestoredData] = useState<SeedPgpPlaintext | null>(null); const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [publicKeyInput, setPublicKeyInput] = useState('');
const [showMnemonic, setShowMnemonic] = useState(false); const [privateKeyInput, setPrivateKeyInput] = useState('');
const [copied, setCopied] = useState(false); const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
const copyToClipboard = async (text: string) => { const [qrPayload, setQrPayload] = useState('');
try { const [recipientFpr, setRecipientFpr] = useState('');
await navigator.clipboard.writeText(text); const [restoreInput, setRestoreInput] = useState('');
setCopied(true); const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null);
window.setTimeout(() => setCopied(false), 1500); const [error, setError] = useState('');
} catch { const [loading, setLoading] = useState(false);
// Fallback for environments where Clipboard API is blocked const [copied, setCopied] = useState(false);
const ta = document.createElement("textarea"); const [showQRScanner, setShowQRScanner] = useState(false);
ta.value = text; const [isReadOnly, setIsReadOnly] = useState(false);
ta.style.position = "fixed"; const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null);
ta.style.left = "-9999px";
document.body.appendChild(ta); useEffect(() => {
ta.focus(); // When entering read-only mode, clear sensitive data for security.
ta.select(); if (isReadOnly) {
document.execCommand("copy"); setMnemonic('');
document.body.removeChild(ta); setBackupMessagePassword('');
setCopied(true); setRestoreMessagePassword('');
window.setTimeout(() => setCopied(false), 1500); setPublicKeyInput('');
} setPrivateKeyInput('');
}; setPrivateKeyPassphrase('');
setQrPayload('');
setRestoreInput('');
const handleBackup = async () => { setDecryptedRestoredMnemonic(null);
setLoading(true); setError('');
setError(''); }
setQrPayload(''); }, [isReadOnly]);
setRecipientFpr('');
// Cleanup session key on component unmount
try { useEffect(() => {
const validation = validateBip39Mnemonic(mnemonic); return () => {
if (!validation.valid) { destroySessionKey();
throw new Error(validation.error); };
} }, []);
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase);
const copyToClipboard = async (text: string) => {
const result = await encryptToSeedPgp({ if (isReadOnly) {
plaintext, setError("Copy to clipboard is disabled in Read-only mode.");
publicKeyArmored: pgpKeyInput || undefined, return;
messagePassword: messagePassword || undefined, }
}); try {
await navigator.clipboard.writeText(text);
setQrPayload(result.framed); setCopied(true);
if (result.recipientFingerprint) { window.setTimeout(() => setCopied(false), 1500);
setRecipientFpr(result.recipientFingerprint); } catch {
} const ta = document.createElement("textarea");
} catch (e) { ta.value = text;
setError(e instanceof Error ? e.message : 'Encryption failed'); ta.style.position = "fixed";
} finally { ta.style.left = "-9999px";
setLoading(false); document.body.appendChild(ta);
} ta.focus();
}; ta.select();
document.execCommand("copy");
const handleRestore = async () => { document.body.removeChild(ta);
setLoading(true); setCopied(true);
setError(''); window.setTimeout(() => setCopied(false), 1500);
setRestoredData(null); }
};
try {
const result = await decryptSeedPgp({ const handleBackup = async () => {
frameText: restoreInput, setLoading(true);
privateKeyArmored: pgpKeyInput || undefined, setError('');
privateKeyPassphrase: privateKeyPassphrase || undefined, setQrPayload('');
messagePassword: messagePassword || undefined, setRecipientFpr('');
});
try {
setRestoredData(result); const validation = validateBip39Mnemonic(mnemonic);
} catch (e) { if (!validation.valid) {
setError(e instanceof Error ? e.message : 'Decryption failed'); throw new Error(validation.error);
} finally { }
setLoading(false);
} const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase);
};
const result = await encryptToSeedPgp({
return ( plaintext,
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 text-slate-900 p-4 md:p-8"> publicKeyArmored: publicKeyInput || undefined,
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200"> messagePassword: backupMessagePassword || undefined,
});
{/* Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between"> setQrPayload(result.framed);
<div className="flex items-center gap-3"> if (result.recipientFingerprint) {
<div className="p-2 bg-blue-600 rounded-lg shadow-lg"> setRecipientFpr(result.recipientFingerprint);
<Shield size={28} /> }
</div>
<div> // Initialize session key before encrypting
<h1 className="text-2xl font-bold tracking-tight"> await getSessionKey();
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.1</span> // Encrypt mnemonic with session key and clear plaintext state
</h1> const blob = await encryptJsonToBlob({ mnemonic, timestamp: Date.now() });
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p> setEncryptedMnemonicCache(blob);
</div> setMnemonic(''); // Clear plaintext mnemonic
</div> } catch (e) {
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur"> setError(e instanceof Error ? e.message : 'Encryption failed');
<button } finally {
onClick={() => { setLoading(false);
setActiveTab('backup'); }
setError(''); };
setQrPayload('');
setRestoredData(null); const handleRestore = async () => {
}} setLoading(true);
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup' setError('');
? 'bg-white text-slate-900 shadow-lg' setDecryptedRestoredMnemonic(null);
: 'text-slate-300 hover:text-white hover:bg-slate-700/50'
}`} try {
> const result = await decryptSeedPgp({
Backup frameText: restoreInput,
</button> privateKeyArmored: privateKeyInput || undefined,
<button privateKeyPassphrase: privateKeyPassphrase || undefined,
onClick={() => { messagePassword: restoreMessagePassword || undefined,
setActiveTab('restore'); });
setError('');
setQrPayload(''); // Encrypt the restored mnemonic with the session key
setRestoredData(null); await getSessionKey();
}} const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() });
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore' setEncryptedMnemonicCache(blob);
? 'bg-white text-slate-900 shadow-lg'
: 'text-slate-300 hover:text-white hover:bg-slate-700/50' // Temporarily display the mnemonic and then clear it
}`} setDecryptedRestoredMnemonic(result.w);
> setTimeout(() => {
Restore setDecryptedRestoredMnemonic(null);
</button> }, 10000); // Auto-clear after 10 seconds
</div>
</div> } catch (e) {
setError(e instanceof Error ? e.message : 'Decryption failed');
<div className="p-6 md:p-8 space-y-6"> } finally {
{/* Error Display */} setLoading(false);
{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> const handleLockAndClear = () => {
<p className="font-bold mb-1">Error</p> destroySessionKey();
<p className="whitespace-pre-wrap">{error}</p> setEncryptedMnemonicCache(null);
</div> setMnemonic('');
</div> setBackupMessagePassword('');
)} setRestoreMessagePassword('');
setPublicKeyInput('');
{/* Info Banner */} setPrivateKeyInput('');
{recipientFpr && activeTab === 'backup' && ( setPrivateKeyPassphrase('');
<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"> setQrPayload('');
<Info size={16} className="shrink-0 mt-0.5" /> setRecipientFpr('');
<div> setRestoreInput('');
<strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code> setDecryptedRestoredMnemonic(null);
</div> setError('');
</div> setCopied(false);
)} setShowQRScanner(false);
};
{/* Main Content Grid */}
<div className="grid gap-6 md:grid-cols-3">
<div className="md:col-span-2 space-y-6"> return (
{activeTab === 'backup' ? ( <>
<> <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="space-y-2"> <div className="max-w-5xl mx-auto bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200">
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
<textarea {/* Header */}
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" <div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white flex items-center justify-between">
placeholder="Enter your 12 or 24 word seed phrase..." <div className="flex items-center gap-3">
value={mnemonic} <div className="p-2 bg-blue-600 rounded-lg shadow-lg">
onChange={(e) => setMnemonic(e.target.value)} <Shield size={28} />
/> </div>
</div> <div>
<h1 className="text-2xl font-bold tracking-tight">
<PgpKeyInput SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v{__APP_VERSION__}</span>
label="PGP Public Key (Optional)" </h1>
icon={FileKey} <p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..." </div>
value={pgpKeyInput} </div>
onChange={setPgpKeyInput} {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"
<> >
<div className="space-y-2"> <Lock size={16} />
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label> <span>Lock/Clear</span>
<textarea </button>
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:..." <div className="flex items-center gap-4">
value={restoreInput} {isReadOnly && (
onChange={(e) => setRestoreInput(e.target.value)} <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} />
</div> <span>Read-only</span>
</div>
<PgpKeyInput )}
label="PGP Private Key (Optional)" <div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
icon={FileKey} <button
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..." onClick={() => {
value={pgpKeyInput} setActiveTab('backup');
onChange={setPgpKeyInput} setError('');
/> setQrPayload('');
setDecryptedRestoredMnemonic(null);
{pgpKeyInput && ( }}
<div className="space-y-2"> className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label> ? 'bg-white text-slate-900 shadow-lg'
<div className="relative"> : 'text-slate-300 hover:text-white hover:bg-slate-700/50'
<Lock className="absolute left-3 top-3 text-slate-400" size={16} /> }`}
<input >
type="password" Backup
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" </button>
placeholder="Unlock private key..." <button
value={privateKeyPassphrase} onClick={() => {
onChange={(e) => setPrivateKeyPassphrase(e.target.value)} setActiveTab('restore');
/> setError('');
</div> setQrPayload('');
</div> setDecryptedRestoredMnemonic(null);
)} }}
</> className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
)} ? 'bg-white text-slate-900 shadow-lg'
</div> : 'text-slate-300 hover:text-white hover:bg-slate-700/50'
}`}
{/* Security Panel */} >
<div className="space-y-6"> Restore
<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"> </button>
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-wider flex items-center gap-2"> </div>
<Lock size={14} /> Security Options </div>
</h3> </div>
<div className="space-y-2"> <div className="p-6 md:p-8 space-y-6">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label> {/* Error Display */}
<div className="relative"> {error && (
<Lock className="absolute left-3 top-3 text-slate-400" size={16} /> <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">
<input <AlertCircle className="shrink-0 mt-0.5" size={20} />
type="password" <div>
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" <p className="font-bold mb-1">Error</p>
placeholder="Optional password..." <p className="whitespace-pre-wrap">{error}</p>
value={messagePassword} </div>
onChange={(e) => setMessagePassword(e.target.value)} </div>
/> )}
</div>
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p> {/* Info Banner */}
</div> {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">
{activeTab === 'backup' && ( <Info size={16} className="shrink-0 mt-0.5" />
<div className="pt-3 border-t border-slate-300"> <div>
<label className="flex items-center gap-2 cursor-pointer group"> <strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
<input </div>
type="checkbox" </div>
checked={hasBip39Passphrase} )}
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all" {/* Main Content Grid */}
/> <div className="grid gap-6 md:grid-cols-3">
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors"> <div className="md:col-span-2 space-y-6">
BIP39 25th word active {activeTab === 'backup' ? (
</span> <>
</label> <div className="space-y-2">
</div> <label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
)} <textarea
</div> 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"
{/* Action Button */} placeholder="Enter your 12 or 24 word seed phrase..."
{activeTab === 'backup' ? ( value={mnemonic}
<button onChange={(e) => setMnemonic(e.target.value)}
onClick={handleBackup} readOnly={isReadOnly}
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" </div>
>
{loading ? ( <PgpKeyInput
<RefreshCw className="animate-spin" size={20} /> label="PGP Public Key (Optional)"
) : ( icon={FileKey}
<QrCode size={20} /> placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;&#10;Paste or drag & drop your public key..."
)} value={publicKeyInput}
{loading ? 'Generating...' : 'Generate QR Backup'} onChange={setPublicKeyInput}
</button> readOnly={isReadOnly}
) : ( />
<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" <div className="flex gap-2">
> <button
{loading ? ( onClick={() => setShowQRScanner(true)}
<RefreshCw className="animate-spin" size={20} /> 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"
<Unlock size={20} /> >
)} <QrCode size={18} />
{loading ? 'Decrypting...' : 'Decrypt & Restore'} Scan QR Code
</button> </button>
)} </div>
</div>
</div> <div className="space-y-2">
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
{/* QR Output */} <textarea
{qrPayload && activeTab === 'backup' && ( 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"
<div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4"> placeholder="SEEDPGP1:0:ABCD:..."
<div className="flex justify-center"> value={restoreInput}
<QrDisplay value={qrPayload} /> onChange={(e) => setRestoreInput(e.target.value)}
</div> readOnly={isReadOnly}
<div className="space-y-2"> />
<div className="flex items-center justify-between gap-3"> </div>
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">
Raw payload (copy for backup) <PgpKeyInput
</label> label="PGP Private Key (Optional)"
icon={FileKey}
<button data-sensitive="PGP Private Key"
type="button" placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----&#10;&#10;Paste or drag & drop your private key..."
onClick={() => copyToClipboard(qrPayload)} value={privateKeyInput}
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" onChange={setPrivateKeyInput}
> readOnly={isReadOnly}
{copied ? <CheckCircle2 size={14} /> : <QrCode size={14} />} />
{copied ? "Copied" : "Copy"}
</button> {privateKeyInput && (
</div> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Private Key Passphrase</label>
<textarea <div className="relative">
readOnly <Lock className="absolute left-3 top-3 text-slate-400" size={16} />
value={qrPayload} <input
onFocus={(e) => e.currentTarget.select()} type="password"
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" 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"
<p className="text-[11px] text-slate-500"> placeholder="Unlock private key..."
Tip: click the box to select all, or use Copy. value={privateKeyPassphrase}
</p> onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
</div> readOnly={isReadOnly}
/>
</div> </div>
)} </div>
)}
{/* Restored Mnemonic */} </>
{restoredData && activeTab === 'restore' && ( )}
<div className="pt-6 border-t border-slate-200 animate-in zoom-in-95"> </div>
<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"> {/* Security Panel */}
<span className="font-bold text-green-700 flex items-center gap-2 text-lg"> <div className="space-y-6">
<CheckCircle2 size={22} /> Mnemonic Recovered <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">
</span> <h3 className="text-sm font-bold text-slate-800 uppercase tracking-wider flex items-center gap-2">
<button <Lock size={14} /> Security Options
onClick={() => setShowMnemonic(!showMnemonic)} </h3>
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
> <div className="space-y-2">
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />} <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
</button> <div className="relative">
</div> <Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input
<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' 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"
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words"> placeholder="Optional password..."
{restoredData.w} value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
</p> onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
</div> readOnly={isReadOnly}
/>
{restoredData.pp === 1 && ( </div>
<div className="mt-4 p-3 bg-orange-100 border border-orange-300 rounded-lg"> <p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
<p className="text-xs text-center text-orange-800 font-bold uppercase tracking-widest flex items-center justify-center gap-2"> </div>
<AlertCircle size={14} /> BIP39 Passphrase Required (25th Word)
</p>
</div> {activeTab === 'backup' && (
)} <div className="pt-3 border-t border-slate-300">
<label className="flex items-center gap-2 cursor-pointer group">
{restoredData.fpr && restoredData.fpr.length > 0 && ( <input
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg"> type="checkbox"
<p className="text-xs text-blue-800"> checked={hasBip39Passphrase}
<strong>Encrypted for keys:</strong> {restoredData.fpr.join(', ')} onChange={(e) => setHasBip39Passphrase(e.target.checked)}
</p> disabled={isReadOnly}
</div> className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
)} />
</div> <span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
</div> BIP39 25th word active
)} </span>
</div> </label>
</div> </div>
)}
{/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500"> <ReadOnly
<p>SeedPGP v1.1 OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p> isReadOnly={isReadOnly}
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p> onToggle={setIsReadOnly}
</div> appVersion={__APP_VERSION__}
</div> buildHash={__BUILD_HASH__}
); />
} </div>
export default App; {/* Action Button */}
{activeTab === 'backup' ? (
<button
onClick={handleBackup}
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"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<QrCode size={20} />
)}
{loading ? 'Generating...' : 'Generate QR Backup'}
</button>
) : (
<button
onClick={handleRestore}
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"
>
{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>
</div>
<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>
</div>
)}
{/* Restored Mnemonic */}
{decryptedRestoredMnemonic && 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={() => setDecryptedRestoredMnemonic(null)}
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
>
<EyeOff size={22} /> Hide
</button>
</div>
<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">
{decryptedRestoredMnemonic}
</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500">
<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>
</div>
</div>
{/* 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;

View 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>
);
};

View File

@@ -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>
<span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200"> {!readOnly && (
Drag & Drop .asc file <span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
</span> Drag & Drop .asc file
</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} />

View 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>
);
}

View File

@@ -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 items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-200"> <div className="flex flex-col items-center gap-4">
<img src={dataUrl} alt="SeedPGP QR Code" className="w-64 h-64" /> <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> </div>
); );
}; };

View 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>
);
}

View 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>
);

View 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>
);
};

View File

@@ -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();

211
src/lib/sessionCrypto.ts Normal file
View File

@@ -0,0 +1,211 @@
/**
* @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> {
console.log('getSessionKey called.');
if (sessionKey) {
console.log('Session key already exists.');
return sessionKey;
}
console.log('Generating new session key...');
const key = await window.crypto.subtle.generateKey(
{
name: KEY_ALGORITHM,
length: KEY_LENGTH,
},
false, // non-exportable
['encrypt', 'decrypt'],
);
sessionKey = key;
console.log('New session key generated and stored.');
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> {
console.log('encryptJsonToBlob called.');
if (!sessionKey) {
console.error('ERROR: Session key not initialized when encryptJsonToBlob was called.');
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;
}

View File

@@ -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
View 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;

View File

@@ -1,6 +1,24 @@
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: '/seedpgp-web-app/',
build: {
outDir: 'dist',
emptyOutDir: false,
},
define: {
'__APP_VERSION__': JSON.stringify(appVersion),
'__BUILD_HASH__': JSON.stringify(gitHash),
}
}) })