mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fbd210ca | ||
|
|
5ea3b92ab1 | ||
|
|
eec194fbba | ||
|
|
24c714fb2f | ||
|
|
eeb5184b8a | ||
|
|
422fe04a12 | ||
|
|
ebeea79a33 | ||
|
|
faf58dc49d | ||
|
|
46982794cc | ||
|
|
9ffdbbd50f | ||
|
|
b024856c08 | ||
|
|
a919e8bf09 | ||
|
|
e4516f3d19 | ||
|
|
4b5bd80be6 | ||
|
|
8124375537 | ||
|
|
2107dab501 | ||
|
|
0f397859e6 | ||
|
|
d4919f3d93 | ||
|
|
c1b1f566df | ||
|
|
6bbfe665cd | ||
|
|
8e656749fe |
383
GEMINI.md
Normal file
383
GEMINI.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# SeedPGP - Gemini Code Assist Project Brief
|
||||
|
||||
## Project Overview
|
||||
|
||||
**SeedPGP v1.4.3**: Client-side BIP39 mnemonic encryption webapp
|
||||
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
|
||||
**Deploy**: GitHub Pages (public repo: `seedpgp-web-app`, private source: `seedpgp-web`)
|
||||
**Live URL**: <https://kccleoc.github.io/seedpgp-web-app/>
|
||||
|
||||
## Core Constraints
|
||||
|
||||
1. **Security-first**: Never persist secrets (mnemonic/passphrase/private keys) to localStorage/sessionStorage/IndexedDB
|
||||
2. **Small PRs**: Max 1-5 files per feature; propose plan before coding
|
||||
3. **Client-side only**: No backend; all crypto runs in browser (Web Crypto API + OpenPGP.js)
|
||||
4. **GitHub Pages deploy**: Base path `/seedpgp-web-app/` configured in vite.config.ts
|
||||
5. **Honest security claims**: Don't overclaim what client-side JS can guarantee
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- Small diffs only: one feature slice per PR (1-5 files if possible)
|
||||
- No big code dumps; propose plan first, then implement
|
||||
- Never persist secrets to browser storage
|
||||
- Prefer "explain what you found in the repo" over guessing
|
||||
- TypeScript strict mode; no `any` types without justification
|
||||
|
||||
---
|
||||
|
||||
## Architecture Map
|
||||
|
||||
### Entry Points
|
||||
|
||||
- `src/main.tsx` → `src/App.tsx` (main application)
|
||||
- Build output: `dist/` (separate git repo for GitHub Pages deployment)
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```BASH
|
||||
src/
|
||||
├── components/ # React UI components
|
||||
│ ├── PgpKeyInput.tsx
|
||||
│ ├── QrDisplay.tsx
|
||||
│ ├── QrScanner.tsx
|
||||
│ ├── ReadOnly.tsx
|
||||
│ ├── StorageIndicator.tsx
|
||||
│ ├── SecurityWarnings.tsx
|
||||
│ └── ClipboardTracker.tsx
|
||||
├── lib/ # Core logic & crypto utilities
|
||||
│ ├── seedpgp.ts # Main encrypt/decrypt functions
|
||||
│ ├── sessionCrypto.ts # Ephemeral AES-GCM session keys
|
||||
│ ├── types.ts # TypeScript interfaces
|
||||
│ └── qr.ts # QR code utilities
|
||||
├── App.tsx # Main app component
|
||||
└── main.tsx # React entry point
|
||||
```
|
||||
|
||||
### Key Modules
|
||||
|
||||
#### `src/lib/seedpgp.ts`
|
||||
|
||||
Core encryption/decryption:
|
||||
|
||||
- `encryptToSeedPgp()` - Encrypts mnemonic with PGP public key + optional password
|
||||
- `decryptFromSeedPgp()` - Decrypts with PGP private key + optional password
|
||||
- Uses OpenPGP.js for PGP operations
|
||||
- Output format: `SEEDPGP1:version:base64data:fingerprint`
|
||||
|
||||
#### `src/lib/sessionCrypto.ts` (v1.3.0+)
|
||||
|
||||
Ephemeral session-key encryption:
|
||||
|
||||
- `getSessionKey()` - Generates/returns non-exportable AES-GCM-256 key (idempotent)
|
||||
- `encryptJsonToBlob(obj)` - Encrypts to `{v, alg, iv_b64, ct_b64}`
|
||||
- `decryptBlobToJson(blob)` - Decrypts back to original object
|
||||
- `destroySessionKey()` - Drops key reference for garbage collection
|
||||
- Test: `await window.runSessionCryptoTest()` (DEV only)
|
||||
|
||||
#### `src/lib/types.ts`
|
||||
|
||||
Core interfaces:
|
||||
|
||||
- `SeedPgpPlaintext` - Decrypted mnemonic data structure
|
||||
- `SeedPgpCiphertext` - Encrypted payload structure
|
||||
- `EncryptedBlob` - Session-key encrypted cache format
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### v1.0 - Core Functionality
|
||||
|
||||
- **Backup**: Encrypt mnemonic with PGP public key + optional password → QR display
|
||||
- **Restore**: Scan/paste QR → decrypt with private key → show mnemonic
|
||||
- **PGP support**: Import public/private keys (.asc files or paste)
|
||||
|
||||
### v1.1 - QR Features
|
||||
|
||||
- **QR Display**: Generate QR codes from encrypted data
|
||||
- **QR Scanner**: Camera + file upload (uses html5-qrcode library)
|
||||
|
||||
### v1.2 - Security Monitoring
|
||||
|
||||
- **Storage Indicator**: Real-time display of localStorage/sessionStorage contents
|
||||
- **Security Warnings**: Context-aware alerts about browser memory limitations
|
||||
- **Clipboard Tracker**: Monitor clipboard operations on sensitive fields
|
||||
- **Read-only Mode**: Toggle to clear state + show CSP/build info
|
||||
|
||||
### v1.3-v1.4 - Session-Key Encryption
|
||||
|
||||
- **Ephemeral encryption**: AES-GCM-256 session key (non-exportable) encrypts sensitive state
|
||||
- **Backup flow (v1.3)**: Mnemonic auto-clears immediately after QR generation
|
||||
- **Restore flow (v1.4)**: Decrypted mnemonic auto-clears after 10 seconds + manual Hide button
|
||||
- **Encrypted cache**: Only ciphertext stored in React state; key lives in memory only
|
||||
- **Lock/Clear**: Manual cleanup destroys session key + clears all state
|
||||
- **Lifecycle**: Session key auto-destroyed on page close/refresh
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
bun install # Install dependencies
|
||||
bun run dev # Dev server (localhost:5173)
|
||||
bun run build # Build to dist/
|
||||
bun run typecheck # TypeScript validation (tsc --noEmit)
|
||||
bun run preview # Preview production build
|
||||
./scripts/deploy.sh v1.x.x # Build + push to public repo
|
||||
```
|
||||
|
||||
### Deployment Process
|
||||
|
||||
**Production:** Cloudflare Pages (auto-deploys from `main` branch)
|
||||
**Live URL:** <https://seedpgp-web.pages.dev>
|
||||
|
||||
### Cloudflare Pages Setup
|
||||
|
||||
1. **Repository:** `seedpgp-web` (private repo)
|
||||
2. **Build command:** `bun run build`
|
||||
3. **Output directory:** `dist/`
|
||||
4. **Security headers:** Automatically enforced via `public/_headers`
|
||||
|
||||
### Benefits Over GitHub Pages
|
||||
|
||||
- ✅ Real CSP header enforcement (blocks network requests at browser level)
|
||||
- ✅ Custom security headers (X-Frame-Options, X-Content-Type-Options)
|
||||
- ✅ Auto-deploy on push to main
|
||||
- ✅ Build preview for PRs
|
||||
- ✅ Better performance (global CDN)
|
||||
|
||||
### Git Workflow
|
||||
|
||||
```bash
|
||||
# Commit feature
|
||||
git add src/
|
||||
git commit -m "feat(v1.x): description"
|
||||
|
||||
# Tag version (triggers auto-deploy to Cloudflare)
|
||||
git tag v1.x.x
|
||||
git push origin main --tags
|
||||
|
||||
# **IMPORTANT: Update README.md before tagging**
|
||||
# Update the following sections in README.md:
|
||||
# - Current version number in header
|
||||
# - Recent Changes section with new features
|
||||
# - Any new usage instructions or screenshots
|
||||
# Then commit the README update:
|
||||
git add README.md
|
||||
git commit -m "docs: update README for v1.x.x"
|
||||
|
||||
# Deploy to GitHub Pages
|
||||
./scripts/deploy.sh v1.x.x
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required Workflow for AI Agents
|
||||
|
||||
### 1. Study First
|
||||
|
||||
Before implementing any feature:
|
||||
|
||||
- Read relevant files
|
||||
- Explain current architecture + entry points
|
||||
- List files that will be touched
|
||||
- Identify potential conflicts or dependencies
|
||||
|
||||
### 2. Plan
|
||||
|
||||
- Propose smallest vertical slice (1-5 files)
|
||||
- Show API signatures or interface changes first
|
||||
- Get approval before generating full implementation
|
||||
|
||||
### 3. Implement
|
||||
|
||||
- Generate code with TypeScript strict mode
|
||||
- Include JSDoc comments for public APIs
|
||||
- Show unified diffs, not full file rewrites (when possible)
|
||||
- Keep changes under 50-100 lines per file when feasible
|
||||
|
||||
### 4. Verify
|
||||
|
||||
- Run `bun run typecheck` - no errors
|
||||
- Run `bun run build` - successful dist/ output
|
||||
- Provide manual test steps for browser verification
|
||||
- Show build output / console logs / DevTools screenshots
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### State Management
|
||||
|
||||
- React `useState` + `useEffect` (no Redux/Zustand/external store)
|
||||
- Ephemeral state only; avoid persistent storage for secrets
|
||||
|
||||
### Styling
|
||||
|
||||
- Tailwind utility classes (configured in `tailwind.config.js`)
|
||||
- Responsive design: mobile-first with `md:` breakpoints
|
||||
- Dark theme primary: slate-900 background, blue-400 accents
|
||||
|
||||
### Icons
|
||||
|
||||
- `lucide-react` library
|
||||
- Common: Shield, QrCode, Lock, Eye, AlertCircle
|
||||
|
||||
### Crypto Operations
|
||||
|
||||
- **PGP**: OpenPGP.js (`openpgp` package)
|
||||
- **Session keys**: Web Crypto API (`crypto.subtle`)
|
||||
- **Key generation**: `crypto.subtle.generateKey()` with `extractable: false`
|
||||
- **Encryption**: AES-GCM with random 12-byte IV per operation
|
||||
|
||||
### Type Safety
|
||||
|
||||
- Strict TypeScript (`tsconfig.json`: `strict: true`)
|
||||
- Check `src/lib/types.ts` for core interfaces
|
||||
- Avoid `any`; use `unknown` + type guards when necessary
|
||||
|
||||
---
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Threat Model (Honest)
|
||||
|
||||
**What we protect against:**
|
||||
|
||||
- Accidental persistence to localStorage/sessionStorage
|
||||
- Plaintext secrets lingering in React state after use
|
||||
- Clipboard history exposure (with warnings)
|
||||
|
||||
**What we DON'T protect against (and must not claim to):**
|
||||
|
||||
- Active XSS or malicious browser extensions
|
||||
- Memory dumps or browser crash reports
|
||||
- JavaScript garbage collection timing (non-deterministic)
|
||||
|
||||
### Memory Handling
|
||||
|
||||
- **Session keys**: Non-exportable CryptoKey objects (Web Crypto API)
|
||||
- **Plaintext clearing**: Set to empty string + drop references (but GC timing is non-deterministic)
|
||||
- **No guarantees**: Cannot force immediate memory wiping in JavaScript
|
||||
|
||||
### Storage Policy
|
||||
|
||||
- **NEVER write to**: localStorage, sessionStorage, IndexedDB, cookies
|
||||
- **Exception**: Non-sensitive UI state only (theme preferences, etc.) - NOT IMPLEMENTED YET
|
||||
- **Verification**: StorageIndicator component monitors all storage APIs
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
### Code Generation
|
||||
|
||||
- Don't generate full file rewrites unless necessary
|
||||
- Don't add dependencies without discussing bundle size impact
|
||||
- Don't use `any` types without explicit justification
|
||||
- Don't skip TypeScript strict mode checks
|
||||
|
||||
### Security Claims
|
||||
|
||||
- Don't claim "RAM is wiped" (JavaScript can't force GC)
|
||||
- Don't claim "offline mode" without real CSP headers (GitHub Pages can't set custom headers)
|
||||
- Don't promise protection against active browser compromise (XSS/extensions)
|
||||
|
||||
### Storage
|
||||
|
||||
- Don't write secrets to storage without explicit approval
|
||||
- Don't cache decrypted data beyond immediate use
|
||||
- Don't assume browser storage is secure
|
||||
|
||||
---
|
||||
|
||||
## Testing & Verification
|
||||
|
||||
### Manual Test Checklist (Before Marking Feature Complete)
|
||||
|
||||
1. ✅ `bun run typecheck` passes (no TypeScript errors)
|
||||
2. ✅ `bun run build` succeeds (dist/ generated)
|
||||
3. ✅ Browser test: Feature works as described
|
||||
4. ✅ DevTools Console: No runtime errors
|
||||
5. ✅ DevTools Application tab: No plaintext secrets in storage
|
||||
6. ✅ DevTools Network tab: No unexpected network calls (if Read-only Mode)
|
||||
|
||||
### Session-Key Encryption Test (v1.3+)
|
||||
|
||||
```javascript
|
||||
// In browser DevTools console:
|
||||
await window.runSessionCryptoTest()
|
||||
// Expected: ✅ Success: Data integrity verified.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current Version: v1.4.3
|
||||
|
||||
*Please update the "Recent Changes", "Known Limitations", and "Next Priorities" sections to reflect the current state of the project.*
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
295
README.md
295
README.md
@@ -1,8 +1,10 @@
|
||||
# SeedPGP v1.1.0
|
||||
# SeedPGP v1.4.3
|
||||
|
||||
**Secure BIP39 mnemonic backup using PGP encryption and QR codes**
|
||||
|
||||
A TypeScript/Bun tool for encrypting cryptocurrency seed phrases with OpenPGP and encoding them as QR-friendly Base45 frames with CRC16 integrity checking.
|
||||
A client-side web app for encrypting cryptocurrency seed phrases with OpenPGP and encoding them as QR-friendly Base45 frames with CRC16 integrity checking.
|
||||
|
||||
**Live App:** <https://seedpgp-web.pages.dev>
|
||||
|
||||
## Features
|
||||
|
||||
@@ -11,7 +13,11 @@ A TypeScript/Bun tool for encrypting cryptocurrency seed phrases with OpenPGP an
|
||||
- ✅ **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
|
||||
- ⚡ **Fast**: Built with Bun runtime and Vite for optimal performance
|
||||
- 🔒 **Session-Key Encryption**: Ephemeral AES-GCM-256 encryption for in-memory protection
|
||||
- 🛡️ **CSP Enforcement**: Real Content Security Policy headers block all network requests
|
||||
- 📸 **QR Scanner**: Camera and file upload support for scanning encrypted QR codes
|
||||
- 👁️ **Security Monitoring**: Real-time storage monitoring and clipboard tracking
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -32,7 +38,30 @@ bun run dev
|
||||
|
||||
## Usage
|
||||
|
||||
### Encrypt a Mnemonic
|
||||
### Web Interface
|
||||
|
||||
Visit <https://seedpgp-web.pages.dev> or run locally:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# Open http://localhost:5173
|
||||
```
|
||||
|
||||
**Backup Flow:**
|
||||
|
||||
1. Enter your BIP39 mnemonic (12/18/24 words)
|
||||
2. Import PGP public key or set encryption password
|
||||
3. Click "Backup" to encrypt and generate QR code
|
||||
4. Save/print QR code for offline storage
|
||||
|
||||
**Restore Flow:**
|
||||
|
||||
1. Scan QR code or paste encrypted text
|
||||
2. Import PGP private key or enter password
|
||||
3. Click "Restore" to decrypt mnemonic
|
||||
4. Mnemonic auto-clears after 10 seconds
|
||||
|
||||
### API Usage
|
||||
|
||||
```typescript
|
||||
import { encryptToSeedPgp, buildPlaintext } from "./lib/seedpgp";
|
||||
@@ -64,106 +93,42 @@ console.log(decrypted.w); // Recovered mnemonic
|
||||
console.log(decrypted.pp); // BIP39 passphrase indicator (0 or 1)
|
||||
```
|
||||
|
||||
## Deployment to GitHub Pages (FREE)
|
||||
## Deployment
|
||||
|
||||
This project uses a two-repository setup to keep source code private while hosting the app for free.
|
||||
**Production:** Cloudflare Pages (auto-deploys from `main` branch)
|
||||
**Live URL:** <https://seedpgp-web.pages.dev>
|
||||
|
||||
### One-Time Setup
|
||||
### Cloudflare Pages Setup
|
||||
|
||||
#### 1. Create Public Deployment Repo
|
||||
This project is deployed on Cloudflare Pages for enhanced security features:
|
||||
|
||||
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
|
||||
1. **Repository:** `seedpgp-web` (private repo)
|
||||
2. **Build command:** `bun run build`
|
||||
3. **Output directory:** `dist/`
|
||||
4. **Security headers:** Automatically enforced via `public/_headers`
|
||||
|
||||
#### 2. Configure Vite Base Path
|
||||
### Benefits Over GitHub Pages
|
||||
|
||||
Edit `vite.config.ts`:
|
||||
- ✅ Real CSP header enforcement (blocks network requests at browser level)
|
||||
- ✅ Custom security headers (X-Frame-Options, X-Content-Type-Options)
|
||||
- ✅ Auto-deploy on push to main
|
||||
- ✅ Build preview for PRs
|
||||
- ✅ Better performance (global CDN)
|
||||
- ✅ Cost: $0/month
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/seedpgp-web-app/', // Match your public repo name
|
||||
})
|
||||
```
|
||||
|
||||
#### 3. Build and Deploy
|
||||
### Deployment Workflow
|
||||
|
||||
```bash
|
||||
# Build the production bundle
|
||||
bun run build
|
||||
# Commit feature
|
||||
git add src/
|
||||
git commit -m "feat(v1.x): description"
|
||||
|
||||
# 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 ..
|
||||
# Tag version (triggers auto-deploy to Cloudflare)
|
||||
git tag v1.x.x
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
#### 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** ✅
|
||||
**No manual deployment needed!** Cloudflare Pages auto-deploys when you push to `main`.
|
||||
|
||||
## Frame Format
|
||||
|
||||
@@ -183,6 +148,7 @@ BASE45 - Base45-encoded PGP message
|
||||
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
|
||||
@@ -194,6 +160,7 @@ Creates a SeedPGP plaintext object.
|
||||
Encrypts a plaintext object to SeedPGP format.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
plaintext: SeedPgpPlaintext;
|
||||
@@ -203,6 +170,7 @@ Encrypts a plaintext object to SeedPGP format.
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
framed: string; // SEEDPGP1 frame
|
||||
@@ -216,6 +184,7 @@ Encrypts a plaintext object to SeedPGP format.
|
||||
Decrypts a SeedPGP frame.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
frameText: string; // SEEDPGP1 frame
|
||||
@@ -256,6 +225,8 @@ bun test --watch
|
||||
- **cv25519** provides ~128-bit security level
|
||||
- **CRC16** detects QR scan errors (not cryptographic)
|
||||
- Key fingerprint validation prevents wrong-key usage
|
||||
- **Session-key encryption**: Ephemeral AES-GCM-256 for in-memory protection
|
||||
- **CSP headers**: Browser-enforced network blocking via Cloudflare Pages
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
@@ -267,50 +238,129 @@ bun test --watch
|
||||
|
||||
### 🔒 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
|
||||
The Cloudflare Pages deployment at **<https://seedpgp-web.pages.dev>** is for:
|
||||
|
||||
- ✅ Personal use with enhanced security
|
||||
- ✅ CSP enforcement blocks all network requests
|
||||
- ✅ Convenient access from any device
|
||||
- ⚠️ 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
|
||||
- Use an airgapped device for critical operations
|
||||
|
||||
### 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:**
|
||||
|
||||
- Active XSS or malicious browser extensions
|
||||
- Memory dumps or browser crash reports
|
||||
- JavaScript garbage collection timing (non-deterministic)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
seedpgp-web/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── PgpKeyInput.tsx # PGP key import UI
|
||||
│ │ ├── QrDisplay.tsx # QR code generation
|
||||
│ │ ├── QrScanner.tsx # Camera + file scanner
|
||||
│ │ ├── ReadOnly.tsx # Read-only mode toggle
|
||||
│ │ ├── StorageIndicator.tsx # Storage monitoring
|
||||
│ │ ├── SecurityWarnings.tsx # Context alerts
|
||||
│ │ └── ClipboardTracker.tsx # Clipboard monitoring
|
||||
│ ├── lib/
|
||||
│ │ ├── seedpgp.ts # Core encryption/decryption
|
||||
│ │ ├── seedpgp.test.ts # Test vectors
|
||||
│ │ ├── sessionCrypto.ts # Ephemeral session keys
|
||||
│ │ ├── base45.ts # Base45 codec
|
||||
│ │ ├── crc16.ts # CRC16-CCITT-FALSE
|
||||
│ │ ├── qr.ts # QR utilities
|
||||
│ │ └── types.ts # TypeScript definitions
|
||||
│ └── App.tsx # React UI
|
||||
├── scripts/
|
||||
│ └── deploy.sh # Deployment automation
|
||||
│ ├── App.tsx # Main application
|
||||
│ └── main.tsx # React entry point
|
||||
├── public/
|
||||
│ └── _headers # Cloudflare CSP headers
|
||||
├── package.json
|
||||
├── DEVELOPMENT.md # Development guide
|
||||
├── vite.config.ts # Vite configuration
|
||||
├── GEMINI.md # AI agent project brief
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: [Bun](https://bun.sh) v1.3.6+
|
||||
- **Language**: TypeScript
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **Crypto**: [OpenPGP.js](https://openpgpjs.org) v6.3.0
|
||||
- **Framework**: React + Vite
|
||||
- **UI**: Tailwind CSS
|
||||
- **Icons**: lucide-react
|
||||
- **QR**: html5-qrcode, qrcode
|
||||
- **Testing**: Bun test runner
|
||||
- **Deployment**: Cloudflare Pages
|
||||
|
||||
## Version History
|
||||
|
||||
### v1.4.3 (2026-01-30)
|
||||
|
||||
- ✅ Fixed textarea contrast for readability
|
||||
- ✅ Fixed overlapping floating boxes
|
||||
- ✅ Polished UI with modern crypto wallet design
|
||||
- ✅ Updated background color to be lighter
|
||||
|
||||
### v1.4.2 (2026-01-30)
|
||||
|
||||
- ✅ Migrated to Cloudflare Pages for real CSP enforcement
|
||||
- ✅ Added "Encrypted in memory" badge when mnemonic locked
|
||||
- ✅ Improved security header configuration
|
||||
- ✅ Updated deployment documentation
|
||||
|
||||
### v1.4.0 (2026-01-29)
|
||||
|
||||
- ✅ Extended session-key encryption to Restore flow
|
||||
- ✅ Added 10-second auto-clear timer for restored mnemonic
|
||||
- ✅ Added manual Hide button for immediate clearing
|
||||
- ✅ Removed debug console logs from production
|
||||
|
||||
### v1.3.0 (2026-01-28)
|
||||
|
||||
- ✅ Implemented ephemeral session-key encryption (AES-GCM-256)
|
||||
- ✅ Auto-clear mnemonic after QR generation (Backup flow)
|
||||
- ✅ Encrypted cache for sensitive state
|
||||
- ✅ Manual Lock/Clear functionality
|
||||
|
||||
### v1.2.0 (2026-01-27)
|
||||
|
||||
- ✅ Added storage monitoring (StorageIndicator)
|
||||
- ✅ Added security warnings (context-aware)
|
||||
- ✅ Added clipboard tracking
|
||||
- ✅ Implemented read-only mode
|
||||
|
||||
### v1.1.0 (2026-01-26)
|
||||
|
||||
- ✅ Initial public release
|
||||
- ✅ QR code generation and scanning
|
||||
- ✅ Full BIP39 mnemonic support
|
||||
- ✅ Trezor test vector validation
|
||||
- ✅ Production-ready implementation
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] QR code generation UI
|
||||
- [ ] QR code scanner with camera support
|
||||
- [ ] UI polish (modern crypto wallet design)
|
||||
- [ ] Multi-frame support for larger payloads
|
||||
- [ ] Hardware wallet integration
|
||||
- [ ] Mobile scanning app
|
||||
- [ ] Shamir Secret Sharing support
|
||||
- [ ] Reproducible builds with git hash verification
|
||||
|
||||
## License
|
||||
|
||||
@@ -320,47 +370,6 @@ MIT License - see LICENSE file for details
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SeedPGP v1.1</title>
|
||||
<title>SeedPGP v1.4.2</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "seedpgp-web",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"html5-qrcode": "^2.3.8",
|
||||
|
||||
6
public/_headers
Normal file
6
public/_headers
Normal file
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; form-action 'none'; base-uri 'self';
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
182
src/App.tsx
182
src/App.tsx
@@ -1,31 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
CheckCircle2, Lock,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Unlock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileKey,
|
||||
Info
|
||||
Info,
|
||||
WifiOff
|
||||
} from 'lucide-react';
|
||||
import { PgpKeyInput } from './components/PgpKeyInput';
|
||||
import { QrDisplay } from './components/QrDisplay';
|
||||
import QRScanner from './components/QRScanner';
|
||||
import { validateBip39Mnemonic } from './lib/bip39';
|
||||
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
|
||||
import type { SeedPgpPlaintext } from './lib/types';
|
||||
import * as openpgp from 'openpgp';
|
||||
import { StorageIndicator } from './components/StorageIndicator';
|
||||
import { SecurityWarnings } from './components/SecurityWarnings';
|
||||
import { ClipboardTracker } from './components/ClipboardTracker';
|
||||
import { ReadOnly } from './components/ReadOnly';
|
||||
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
|
||||
|
||||
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
||||
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
||||
|
||||
function App() {
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
|
||||
const [mnemonic, setMnemonic] = useState('');
|
||||
const [backupMessagePassword, setBackupMessagePassword] = useState('');
|
||||
@@ -38,14 +38,43 @@ function App() {
|
||||
const [qrPayload, setQrPayload] = useState('');
|
||||
const [recipientFpr, setRecipientFpr] = useState('');
|
||||
const [restoreInput, setRestoreInput] = useState('');
|
||||
const [restoredData, setRestoredData] = useState<SeedPgpPlaintext | null>(null);
|
||||
const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showMnemonic, setShowMnemonic] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showQRScanner, setShowQRScanner] = useState(false);
|
||||
const [isReadOnly, setIsReadOnly] = useState(false);
|
||||
const [encryptedMnemonicCache, setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// When entering read-only mode, clear sensitive data for security.
|
||||
if (isReadOnly) {
|
||||
setMnemonic('');
|
||||
setBackupMessagePassword('');
|
||||
setRestoreMessagePassword('');
|
||||
setPublicKeyInput('');
|
||||
setPrivateKeyInput('');
|
||||
setPrivateKeyPassphrase('');
|
||||
setQrPayload('');
|
||||
setRestoreInput('');
|
||||
setDecryptedRestoredMnemonic(null);
|
||||
setError('');
|
||||
}
|
||||
}, [isReadOnly]);
|
||||
|
||||
// Cleanup session key on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
destroySessionKey();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
if (isReadOnly) {
|
||||
setError("Copy to clipboard is disabled in Read-only mode.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
@@ -82,13 +111,20 @@ function App() {
|
||||
const result = await encryptToSeedPgp({
|
||||
plaintext,
|
||||
publicKeyArmored: publicKeyInput || undefined,
|
||||
messagePassword: backupMessagePassword || undefined, // Changed
|
||||
messagePassword: backupMessagePassword || undefined,
|
||||
});
|
||||
|
||||
setQrPayload(result.framed);
|
||||
if (result.recipientFingerprint) {
|
||||
setRecipientFpr(result.recipientFingerprint);
|
||||
}
|
||||
|
||||
// Initialize session key before encrypting
|
||||
await getSessionKey();
|
||||
// Encrypt mnemonic with session key and clear plaintext state
|
||||
const blob = await encryptJsonToBlob({ mnemonic, timestamp: Date.now() });
|
||||
setEncryptedMnemonicCache(blob);
|
||||
setMnemonic(''); // Clear plaintext mnemonic
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Encryption failed');
|
||||
} finally {
|
||||
@@ -99,18 +135,27 @@ function App() {
|
||||
const handleRestore = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setRestoredData(null);
|
||||
setDecryptedRestoredMnemonic(null);
|
||||
|
||||
try {
|
||||
const result = await decryptSeedPgp({
|
||||
frameText: restoreInput,
|
||||
privateKeyArmored: privateKeyInput || undefined,
|
||||
privateKeyPassphrase: privateKeyPassphrase || undefined,
|
||||
messagePassword: restoreMessagePassword || undefined, // Changed
|
||||
messagePassword: restoreMessagePassword || undefined,
|
||||
});
|
||||
|
||||
// Encrypt the restored mnemonic with the session key
|
||||
await getSessionKey();
|
||||
const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() });
|
||||
setEncryptedMnemonicCache(blob);
|
||||
|
||||
// Temporarily display the mnemonic and then clear it
|
||||
setDecryptedRestoredMnemonic(result.w);
|
||||
setTimeout(() => {
|
||||
setDecryptedRestoredMnemonic(null);
|
||||
}, 10000); // Auto-clear after 10 seconds
|
||||
|
||||
setRestoredData(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Decryption failed');
|
||||
} finally {
|
||||
@@ -118,6 +163,24 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLockAndClear = () => {
|
||||
destroySessionKey();
|
||||
setEncryptedMnemonicCache(null);
|
||||
setMnemonic('');
|
||||
setBackupMessagePassword('');
|
||||
setRestoreMessagePassword('');
|
||||
setPublicKeyInput('');
|
||||
setPrivateKeyInput('');
|
||||
setPrivateKeyPassphrase('');
|
||||
setQrPayload('');
|
||||
setRecipientFpr('');
|
||||
setRestoreInput('');
|
||||
setDecryptedRestoredMnemonic(null);
|
||||
setError('');
|
||||
setCopied(false);
|
||||
setShowQRScanner(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -132,18 +195,40 @@ function App() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v1.2</span>
|
||||
SeedPGP <span className="text-blue-400 font-mono text-base ml-2">v{__APP_VERSION__}</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5">OpenPGP-secured BIP39 backup</p>
|
||||
</div>
|
||||
</div>
|
||||
{encryptedMnemonicCache && ( // Show only if encrypted data exists
|
||||
<button
|
||||
onClick={handleLockAndClear}
|
||||
className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
<Lock size={16} />
|
||||
<span>Lock/Clear</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
{isReadOnly && (
|
||||
<div className="flex items-center gap-2 text-sm text-amber-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
|
||||
<WifiOff size={16} />
|
||||
<span>Read-only</span>
|
||||
</div>
|
||||
)}
|
||||
{encryptedMnemonicCache && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-400 bg-slate-800/50 px-3 py-1.5 rounded-lg">
|
||||
<Shield size={16} />
|
||||
<span>Encrypted in memory</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex bg-slate-800/50 rounded-lg p-1 backdrop-blur">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('backup');
|
||||
setError('');
|
||||
setQrPayload('');
|
||||
setRestoredData(null);
|
||||
setDecryptedRestoredMnemonic(null);
|
||||
}}
|
||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'backup'
|
||||
? 'bg-white text-slate-900 shadow-lg'
|
||||
@@ -157,7 +242,7 @@ function App() {
|
||||
setActiveTab('restore');
|
||||
setError('');
|
||||
setQrPayload('');
|
||||
setRestoredData(null);
|
||||
setDecryptedRestoredMnemonic(null);
|
||||
}}
|
||||
className={`px-5 py-2 rounded-md text-sm font-semibold transition-all ${activeTab === 'restore'
|
||||
? 'bg-white text-slate-900 shadow-lg'
|
||||
@@ -168,6 +253,7 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
{/* Error Display */}
|
||||
@@ -204,6 +290,7 @@ function App() {
|
||||
placeholder="Enter your 12 or 24 word seed phrase..."
|
||||
value={mnemonic}
|
||||
onChange={(e) => setMnemonic(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -213,6 +300,7 @@ function App() {
|
||||
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- Paste or drag & drop your public key..."
|
||||
value={publicKeyInput}
|
||||
onChange={setPublicKeyInput}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -220,7 +308,8 @@ function App() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowQRScanner(true)}
|
||||
className="flex-1 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-purple-700 hover:to-purple-800 transition-all shadow-lg"
|
||||
disabled={isReadOnly}
|
||||
className="flex-1 py-3 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-purple-700 hover:to-purple-800 transition-all shadow-lg disabled:opacity-50"
|
||||
>
|
||||
<QrCode size={18} />
|
||||
Scan QR Code
|
||||
@@ -234,6 +323,7 @@ function App() {
|
||||
placeholder="SEEDPGP1:0:ABCD:..."
|
||||
value={restoreInput}
|
||||
onChange={(e) => setRestoreInput(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -244,6 +334,7 @@ function App() {
|
||||
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
||||
value={privateKeyInput}
|
||||
onChange={setPrivateKeyInput}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
|
||||
{privateKeyInput && (
|
||||
@@ -258,6 +349,7 @@ function App() {
|
||||
placeholder="Unlock private key..."
|
||||
value={privateKeyPassphrase}
|
||||
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,6 +375,7 @@ function App() {
|
||||
placeholder="Optional password..."
|
||||
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
|
||||
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Symmetric encryption password (SKESK)</p>
|
||||
@@ -296,6 +389,7 @@ function App() {
|
||||
type="checkbox"
|
||||
checked={hasBip39Passphrase}
|
||||
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
|
||||
disabled={isReadOnly}
|
||||
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">
|
||||
@@ -304,13 +398,20 @@ function App() {
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReadOnly
|
||||
isReadOnly={isReadOnly}
|
||||
onToggle={setIsReadOnly}
|
||||
appVersion={__APP_VERSION__}
|
||||
buildHash={__BUILD_HASH__}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
{activeTab === 'backup' ? (
|
||||
<button
|
||||
onClick={handleBackup}
|
||||
disabled={!mnemonic || loading}
|
||||
disabled={!mnemonic || loading || isReadOnly}
|
||||
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-blue-600 disabled:hover:to-blue-700"
|
||||
>
|
||||
{loading ? (
|
||||
@@ -323,7 +424,7 @@ function App() {
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRestore}
|
||||
disabled={!restoreInput || loading}
|
||||
disabled={!restoreInput || loading || isReadOnly}
|
||||
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
@@ -373,7 +474,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{/* Restored Mnemonic */}
|
||||
{restoredData && activeTab === 'restore' && (
|
||||
{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">
|
||||
@@ -381,35 +482,18 @@ function App() {
|
||||
<CheckCircle2 size={22} /> Mnemonic Recovered
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowMnemonic(!showMnemonic)}
|
||||
onClick={() => setDecryptedRestoredMnemonic(null)}
|
||||
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
|
||||
>
|
||||
{showMnemonic ? <EyeOff size={22} /> : <Eye size={22} />}
|
||||
<EyeOff size={22} /> Hide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm transition-all duration-300 ${showMnemonic ? 'blur-0' : 'blur-lg select-none'
|
||||
}`}>
|
||||
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm">
|
||||
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
|
||||
{restoredData.w}
|
||||
{decryptedRestoredMnemonic}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{restoredData.pp === 1 && (
|
||||
<div className="mt-4 p-3 bg-orange-100 border border-orange-300 rounded-lg">
|
||||
<p className="text-xs text-center text-orange-800 font-bold uppercase tracking-widest flex items-center justify-center gap-2">
|
||||
<AlertCircle size={14} /> BIP39 Passphrase Required (25th Word)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restoredData.fpr && restoredData.fpr.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>Encrypted for keys:</strong> {restoredData.fpr.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -418,7 +502,7 @@ function App() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-slate-500">
|
||||
<p>SeedPGP v1.2 • OpenPGP (RFC 4880) + Base45 (RFC 9285) + CRC16/CCITT-FALSE</p>
|
||||
<p>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>
|
||||
@@ -440,13 +524,17 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* Floating Storage Monitor - bottom right */}
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<StorageIndicator />
|
||||
<SecurityWarnings /> {/* Bottom-left */}
|
||||
<ClipboardTracker /> {/* Top-right */}
|
||||
<SecurityWarnings />
|
||||
<ClipboardTracker />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface PgpKeyInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
|
||||
|
||||
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
||||
@@ -17,21 +16,25 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
||||
onChange,
|
||||
placeholder,
|
||||
label,
|
||||
icon: Icon
|
||||
icon: Icon,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (readOnly) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (readOnly) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (readOnly) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
@@ -53,24 +56,27 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
||||
<span className="flex items-center gap-2">
|
||||
{Icon && <Icon size={14} />} {label}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<span className="text-[10px] text-slate-400 font-normal bg-slate-100 px-2 py-0.5 rounded-full border border-slate-200">
|
||||
Drag & Drop .asc file
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div
|
||||
className={`relative transition-all duration-200 ${isDragging ? 'scale-[1.01]' : ''}`}
|
||||
className={`relative transition-all duration-200 ${isDragging && !readOnly ? 'scale-[1.01]' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<textarea
|
||||
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-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}
|
||||
value={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="text-blue-600 font-bold flex flex-col items-center animate-bounce">
|
||||
<Upload size={24} />
|
||||
|
||||
39
src/components/ReadOnly.tsx
Normal file
39
src/components/ReadOnly.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { WifiOff } from 'lucide-react';
|
||||
|
||||
type ReadOnlyProps = {
|
||||
isReadOnly: boolean;
|
||||
onToggle: (isReadOnly: boolean) => void;
|
||||
buildHash: string;
|
||||
appVersion: string;
|
||||
};
|
||||
|
||||
const CSP_POLICY = `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none';`;
|
||||
|
||||
export function ReadOnly({ isReadOnly, onToggle, buildHash, appVersion }: ReadOnlyProps) {
|
||||
return (
|
||||
<div className="pt-3 border-t border-slate-300">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isReadOnly}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||
Read-only Mode
|
||||
</span>
|
||||
</label>
|
||||
{isReadOnly && (
|
||||
<div className="mt-4 p-3 bg-slate-800 text-slate-200 rounded-lg text-xs space-y-2 animate-in fade-in">
|
||||
<p className="font-bold flex items-center gap-2"><WifiOff size={14} /> Network & Persistence Disabled</p>
|
||||
<div className="font-mono text-[10px] space-y-1">
|
||||
<p><span className="font-semibold text-slate-400">Version:</span> {appVersion}</p>
|
||||
<p><span className="font-semibold text-slate-400">Build:</span> {buildHash}</p>
|
||||
<p className="pt-1 font-semibold text-slate-400">Content Security Policy:</p>
|
||||
<p className="text-sky-300 break-words">{CSP_POLICY}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/lib/sessionCrypto.ts
Normal file
205
src/lib/sessionCrypto.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @file Ephemeral, per-session, in-memory encryption using Web Crypto API.
|
||||
*
|
||||
* This module manages a single, non-exportable AES-GCM key for a user's session.
|
||||
* It's designed to encrypt sensitive data (like a mnemonic) before it's placed
|
||||
* into React state, mitigating the risk of plaintext data in memory snapshots.
|
||||
* The key is destroyed when the user navigates away or the session ends.
|
||||
*/
|
||||
|
||||
// --- Helper functions for encoding ---
|
||||
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binString = atob(base64);
|
||||
return Uint8Array.from(binString, (m) => m.codePointAt(0)!);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const binString = Array.from(bytes, (byte) =>
|
||||
String.fromCodePoint(byte),
|
||||
).join("");
|
||||
return btoa(binString);
|
||||
}
|
||||
|
||||
// --- Module-level state ---
|
||||
|
||||
/**
|
||||
* Holds the session's AES-GCM key. This variable is not exported and is
|
||||
* only accessible through the functions in this module.
|
||||
* @private
|
||||
*/
|
||||
let sessionKey: CryptoKey | null = null;
|
||||
const KEY_ALGORITHM = 'AES-GCM';
|
||||
const KEY_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* An object containing encrypted data and necessary metadata for decryption.
|
||||
*/
|
||||
export interface EncryptedBlob {
|
||||
v: 1;
|
||||
/**
|
||||
* The algorithm used. This is metadata; the actual Web Crypto API call
|
||||
* uses `{ name: "AES-GCM", length: 256 }`.
|
||||
*/
|
||||
alg: 'A256GCM';
|
||||
iv_b64: string; // Initialization Vector (base64)
|
||||
ct_b64: string; // Ciphertext (base64)
|
||||
}
|
||||
|
||||
// --- Core API Functions ---
|
||||
|
||||
/**
|
||||
* Generates and stores a session-level AES-GCM 256-bit key.
|
||||
* The key is non-exportable and is held in a private module-level variable.
|
||||
* If a key already exists, the existing key is returned, making the function idempotent.
|
||||
* This function must be called before any encryption or decryption can occur.
|
||||
* @returns A promise that resolves to the generated or existing CryptoKey.
|
||||
*/
|
||||
export async function getSessionKey(): Promise<CryptoKey> {
|
||||
if (sessionKey) {
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: KEY_ALGORITHM,
|
||||
length: KEY_LENGTH,
|
||||
},
|
||||
false, // non-exportable
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
sessionKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a JSON-serializable object using the current session key.
|
||||
* @param data The object to encrypt. Must be JSON-serializable.
|
||||
* @returns A promise that resolves to an EncryptedBlob.
|
||||
*/
|
||||
export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
|
||||
if (!sessionKey) {
|
||||
throw new Error('Session key not initialized. Call getSessionKey() first.');
|
||||
}
|
||||
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV is recommended for AES-GCM
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
||||
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: KEY_ALGORITHM,
|
||||
iv: new Uint8Array(iv),
|
||||
},
|
||||
sessionKey,
|
||||
plaintext,
|
||||
);
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
alg: 'A256GCM',
|
||||
iv_b64: bytesToBase64(iv),
|
||||
ct_b64: bytesToBase64(new Uint8Array(ciphertext)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an EncryptedBlob back into its original object form.
|
||||
* @param blob The EncryptedBlob to decrypt.
|
||||
* @returns A promise that resolves to the original decrypted object.
|
||||
*/
|
||||
export async function decryptBlobToJson<T>(blob: EncryptedBlob): Promise<T> {
|
||||
if (!sessionKey) {
|
||||
throw new Error('Session key not initialized or has been destroyed.');
|
||||
}
|
||||
if (blob.v !== 1 || blob.alg !== 'A256GCM') {
|
||||
throw new Error('Invalid or unsupported encrypted blob format.');
|
||||
}
|
||||
|
||||
const iv = base64ToBytes(blob.iv_b64);
|
||||
const ciphertext = base64ToBytes(blob.ct_b64);
|
||||
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: KEY_ALGORITHM,
|
||||
iv: new Uint8Array(iv),
|
||||
},
|
||||
sessionKey,
|
||||
new Uint8Array(ciphertext),
|
||||
);
|
||||
|
||||
const jsonString = new TextDecoder().decode(decrypted);
|
||||
return JSON.parse(jsonString) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the session key reference, making it unavailable for future
|
||||
* operations and allowing it to be garbage collected.
|
||||
*/
|
||||
export function destroySessionKey(): void {
|
||||
sessionKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A standalone test function that can be run in the browser console
|
||||
* to verify the complete encryption and decryption lifecycle.
|
||||
*
|
||||
* To use:
|
||||
* 1. Copy this entire function into the browser's developer console.
|
||||
* 2. Run it by typing: `await runSessionCryptoTest()`
|
||||
* 3. Check the console for logs.
|
||||
*/
|
||||
export async function runSessionCryptoTest(): Promise<void> {
|
||||
console.log('--- Running Session Crypto Test ---');
|
||||
try {
|
||||
// 1. Destroy any old key
|
||||
destroySessionKey();
|
||||
console.log('Old key destroyed (if any).');
|
||||
|
||||
// 2. Generate a new key
|
||||
await getSessionKey();
|
||||
console.log('New session key generated.');
|
||||
|
||||
// 3. Define a secret object
|
||||
const originalObject = {
|
||||
mnemonic: 'fee table visa input phrase lake buffalo vague merit million mesh blend',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
console.log('Original object:', originalObject);
|
||||
|
||||
// 4. Encrypt the object
|
||||
const encrypted = await encryptJsonToBlob(originalObject);
|
||||
console.log('Encrypted blob:', encrypted);
|
||||
if (typeof encrypted.ct_b64 !== 'string' || encrypted.ct_b64.length < 20) {
|
||||
throw new Error('Encryption failed: ciphertext looks invalid.');
|
||||
}
|
||||
|
||||
// 5. Decrypt the object
|
||||
const decrypted = await decryptBlobToJson(encrypted);
|
||||
console.log('Decrypted object:', decrypted);
|
||||
|
||||
// 6. Verify integrity
|
||||
if (JSON.stringify(originalObject) !== JSON.stringify(decrypted)) {
|
||||
throw new Error('Verification failed: Decrypted data does not match original data.');
|
||||
}
|
||||
console.log('%c✅ Success: Data integrity verified.', 'color: green; font-weight: bold;');
|
||||
|
||||
// 7. Test key destruction
|
||||
destroySessionKey();
|
||||
console.log('Session key destroyed.');
|
||||
try {
|
||||
await decryptBlobToJson(encrypted);
|
||||
} catch (e) {
|
||||
console.log('As expected, decryption failed after key destruction:', (e as Error).message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('%c❌ Test Failed:', 'color: red; font-weight: bold;', error);
|
||||
} finally {
|
||||
console.log('--- Test Complete ---');
|
||||
}
|
||||
}
|
||||
|
||||
// For convenience, attach the test runner to the window object.
|
||||
// This is for development/testing only and can be removed in production.
|
||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||
(window as any).runSessionCryptoTest = runSessionCryptoTest;
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
await import('./lib/sessionCrypto');
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@@ -6,3 +6,5 @@ declare module '*.css' {
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __BUILD_HASH__: string;
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
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({
|
||||
plugins: [react()],
|
||||
base: '/seedpgp-web-app/',
|
||||
base: process.env.CF_PAGES ? '/' : '/seedpgp-web-app/',
|
||||
publicDir: 'public', // ← Explicitly set (should be default)
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: false,
|
||||
},
|
||||
define: {
|
||||
'__APP_VERSION__': JSON.stringify(appVersion),
|
||||
'__BUILD_HASH__': JSON.stringify(gitHash),
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user