21 Commits

Author SHA1 Message Date
LC mac
81fbd210ca chore: Bump version to 1.4.3 2026-01-30 18:39:30 +08:00
LC mac
5ea3b92ab1 docs: Update version to 1.4.2 in GEMINI.md 2026-01-30 17:33:08 +08:00
LC mac
eec194fbba docs: Revert deployment process and update version in GEMINI.md 2026-01-30 17:26:27 +08:00
LC mac
24c714fb2f update index to 1.4.2 2026-01-30 02:13:53 +08:00
LC mac
eeb5184b8a Cloudflare Pages migration with enforced CSP headers 2026-01-30 02:11:06 +08:00
LC mac
422fe04a12 fix: Copy _headers to dist during build 2026-01-30 01:59:24 +08:00
LC mac
ebeea79a33 chore: Force Cloudflare rebuild with correct base path 2026-01-30 01:49:01 +08:00
LC mac
faf58dc49d fix: Auto-detect base path for Cloudflare vs GitHub Pages 2026-01-30 01:44:53 +08:00
LC mac
46982794cc feat(v1.4): Add 'Encrypted in memory' badge 2026-01-30 01:25:09 +08:00
LC mac
9ffdbbd50f feat(v1.4): Add 'Encrypted in memory' badge 2026-01-30 01:21:28 +08:00
LC mac
b024856c08 docs: update GEMINI.md for v1.4.0 + remove debug logs 2026-01-30 00:44:46 +08:00
LC mac
a919e8bf09 chore: small fix in 1.4.0 2026-01-30 00:36:09 +08:00
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
12 changed files with 1354 additions and 597 deletions

383
GEMINI.md Normal file
View 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

307
README.md
View File

@@ -1,8 +1,10 @@
# SeedPGP v1.1.0 # SeedPGP v1.4.3
**Secure BIP39 mnemonic backup using PGP encryption and QR codes** **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 ## 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 -**Integrity Checking**: CRC16-CCITT-FALSE checksums prevent corruption
- 🔑 **BIP39 Support**: Full support for 12/18/24-word mnemonics with passphrase indicator - 🔑 **BIP39 Support**: Full support for 12/18/24-word mnemonics with passphrase indicator
- 🧪 **Battle-Tested**: Validated against official Trezor BIP39 test vectors - 🧪 **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 ## Installation
@@ -32,7 +38,30 @@ bun run dev
## Usage ## 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 ```typescript
import { encryptToSeedPgp, buildPlaintext } from "./lib/seedpgp"; 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) 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: 1. **Repository:** `seedpgp-web` (private repo)
- **Name**: `seedpgp-web-app` (or any name you prefer) 2. **Build command:** `bun run build`
- **Visibility**: **Public** 3. **Output directory:** `dist/`
- **Don't** initialize with README, .gitignore, or license 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 ### Deployment Workflow
export default defineConfig({
plugins: [react()],
base: '/seedpgp-web-app/', // Match your public repo name
})
```
#### 3. Build and Deploy
```bash ```bash
# Build the production bundle # Commit feature
bun run build git add src/
git commit -m "feat(v1.x): description"
# Initialize git in dist folder # Tag version (triggers auto-deploy to Cloudflare)
cd dist git tag v1.x.x
git init git push origin main --tags
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 **No manual deployment needed!** Cloudflare Pages auto-deploys when you push to `main`.
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 ## Frame Format
@@ -183,6 +148,7 @@ BASE45 - Base45-encoded PGP message
Creates a SeedPGP plaintext object. Creates a SeedPGP plaintext object.
**Parameters:** **Parameters:**
- `mnemonic` (string): BIP39 mnemonic phrase (12/18/24 words) - `mnemonic` (string): BIP39 mnemonic phrase (12/18/24 words)
- `bip39PassphraseUsed` (boolean): Whether a BIP39 passphrase was used - `bip39PassphraseUsed` (boolean): Whether a BIP39 passphrase was used
- `recipientFingerprints` (string[]): Optional array of recipient key fingerprints - `recipientFingerprints` (string[]): Optional array of recipient key fingerprints
@@ -194,6 +160,7 @@ Creates a SeedPGP plaintext object.
Encrypts a plaintext object to SeedPGP format. Encrypts a plaintext object to SeedPGP format.
**Parameters:** **Parameters:**
```typescript ```typescript
{ {
plaintext: SeedPgpPlaintext; plaintext: SeedPgpPlaintext;
@@ -203,6 +170,7 @@ Encrypts a plaintext object to SeedPGP format.
``` ```
**Returns:** **Returns:**
```typescript ```typescript
{ {
framed: string; // SEEDPGP1 frame framed: string; // SEEDPGP1 frame
@@ -216,6 +184,7 @@ Encrypts a plaintext object to SeedPGP format.
Decrypts a SeedPGP frame. Decrypts a SeedPGP frame.
**Parameters:** **Parameters:**
```typescript ```typescript
{ {
frameText: string; // SEEDPGP1 frame frameText: string; // SEEDPGP1 frame
@@ -256,6 +225,8 @@ bun test --watch
- **cv25519** provides ~128-bit security level - **cv25519** provides ~128-bit security level
- **CRC16** detects QR scan errors (not cryptographic) - **CRC16** detects QR scan errors (not cryptographic)
- Key fingerprint validation prevents wrong-key usage - 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 ### ⚠️ Important Notes
@@ -267,50 +238,129 @@ bun test --watch
### 🔒 Production Deployment Warning ### 🔒 Production Deployment Warning
The GitHub Pages deployment at **https://kccleoc.github.io/seedpgp-web-app/** is for: The Cloudflare Pages deployment at **<https://seedpgp-web.pages.dev>** is for:
- ✅ Testing and demonstration
-Convenient access for personal use -Personal use with enhanced security
- ✅ CSP enforcement blocks all network requests
- ✅ Convenient access from any device
- ⚠️ Always verify the URL before use - ⚠️ Always verify the URL before use
For maximum security with real funds: For maximum security with real funds:
- Run locally: `bun run dev` - Run locally: `bun run dev`
- Or self-host on your own domain with HTTPS - 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 ## Project Structure
``` ```
seedpgp-web/ seedpgp-web/
├── src/ ├── 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/ │ ├── lib/
│ │ ├── seedpgp.ts # Core encryption/decryption │ │ ├── seedpgp.ts # Core encryption/decryption
│ │ ├── seedpgp.test.ts # Test vectors │ │ ├── seedpgp.test.ts # Test vectors
│ │ ├── base45.ts # Base45 codec │ │ ├── sessionCrypto.ts # Ephemeral session keys
│ │ ├── crc16.ts # CRC16-CCITT-FALSE │ │ ├── base45.ts # Base45 codec
│ │ ── types.ts # TypeScript definitions │ │ ── crc16.ts # CRC16-CCITT-FALSE
└── App.tsx # React UI │ ├── qr.ts # QR utilities
├── scripts/ │ │ └── types.ts # TypeScript definitions
── deploy.sh # Deployment automation ── App.tsx # Main application
│ └── main.tsx # React entry point
├── public/
│ └── _headers # Cloudflare CSP headers
├── package.json ├── package.json
├── DEVELOPMENT.md # Development guide ├── vite.config.ts # Vite configuration
── README.md # This file ── GEMINI.md # AI agent project brief
└── README.md # This file
``` ```
## Tech Stack ## Tech Stack
- **Runtime**: [Bun](https://bun.sh) v1.3.6+ - **Runtime**: [Bun](https://bun.sh) v1.3.6+
- **Language**: TypeScript - **Language**: TypeScript (strict mode)
- **Crypto**: [OpenPGP.js](https://openpgpjs.org) v6.3.0 - **Crypto**: [OpenPGP.js](https://openpgpjs.org) v6.3.0
- **Framework**: React + Vite - **Framework**: React + Vite
- **UI**: Tailwind CSS
- **Icons**: lucide-react
- **QR**: html5-qrcode, qrcode
- **Testing**: Bun test runner - **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 ## Roadmap
- [ ] QR code generation UI - [ ] UI polish (modern crypto wallet design)
- [ ] QR code scanner with camera support
- [ ] Multi-frame support for larger payloads - [ ] Multi-frame support for larger payloads
- [ ] Hardware wallet integration - [ ] Hardware wallet integration
- [ ] Mobile scanning app - [ ] Mobile scanning app
- [ ] Shamir Secret Sharing support - [ ] Shamir Secret Sharing support
- [ ] Reproducible builds with git hash verification
## License ## License
@@ -320,47 +370,6 @@ MIT License - see LICENSE file for details
**kccleoc** - [GitHub](https://github.com/kccleoc) **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. ⚠️ **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

@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SeedPGP v1.1</title> <title>SeedPGP v1.4.2</title>
</head> </head>
<body> <body>

View File

@@ -1,12 +1,13 @@
{ {
"name": "seedpgp-web", "name": "seedpgp-web",
"private": true, "private": true,
"version": "1.1.0", "version": "1.4.3",
"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", "html5-qrcode": "^2.3.8",

6
public/_headers Normal file
View 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

View File

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

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

View File

@@ -23,6 +23,10 @@ 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 />

2
src/vite-env.d.ts vendored
View File

@@ -6,3 +6,5 @@ declare module '*.css' {
export default content; export default content;
} }
declare const __APP_VERSION__: string;
declare const __BUILD_HASH__: string;

View File

@@ -1,11 +1,25 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
import fs from 'fs'
// Read version from package.json
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf-8'))
const appVersion = packageJson.version
// Get git commit hash
const gitHash = execSync('git rev-parse --short HEAD').toString().trim()
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: '/seedpgp-web-app/', base: process.env.CF_PAGES ? '/' : '/seedpgp-web-app/',
publicDir: 'public', // ← Explicitly set (should be default)
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: false, emptyOutDir: false,
},
define: {
'__APP_VERSION__': JSON.stringify(appVersion),
'__BUILD_HASH__': JSON.stringify(gitHash),
} }
}) })