mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4018dcfe | ||
|
|
94764248d0 | ||
|
|
a4a737540a | ||
|
|
b7da81c5d4 |
115
public/README.md
115
public/README.md
@@ -1,48 +1,121 @@
|
|||||||
# SeedPGP Web App
|
# SeedPGP Web App
|
||||||
|
|
||||||
**Secure BIP39 mnemonic backup tool using PGP encryption**
|
**Secure BIP39 mnemonic backup tool using OpenPGP encryption**
|
||||||
|
|
||||||
🔗 **Live App**: https://kccleoc.github.io/seedpgp-web-app/
|
🔗 **Live App**: https://kccleoc.github.io/seedpgp-web-app/
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
This is a client-side web application for encrypting cryptocurrency seed phrases (BIP39 mnemonics) using OpenPGP encryption and Base45 encoding for QR code generation.
|
Client-side web application for encrypting cryptocurrency seed phrases (BIP39 mnemonics) using OpenPGP encryption with QR code generation and scanning capabilities.
|
||||||
|
|
||||||
### Features
|
### ✨ Features
|
||||||
|
|
||||||
- 🔐 PGP encryption with cv25519 (Curve25519)
|
- 🔐 **OpenPGP Encryption** - Curve25519Legacy (cv25519) encryption
|
||||||
- 📱 QR code-ready output (Base45 + CRC16)
|
- 📱 **QR Code Generation** - High-quality 512x512px PNG with download
|
||||||
- ✅ Supports 12/18/24-word BIP39 mnemonics
|
- 📸 **QR Code Scanner** - Camera or image upload with live preview
|
||||||
- 🔒 All encryption happens in your browser (client-side only)
|
- 🔄 **Round-trip Flow** - Encrypt → QR → Scan → Decrypt seamlessly
|
||||||
|
- ✅ **BIP39 Support** - 12/18/24-word mnemonics with optional passphrase
|
||||||
|
- 🔒 **Symmetric Encryption** - Optional password-only encryption (SKESK)
|
||||||
|
- 🎯 **CRC16 Validation** - Frame integrity checking
|
||||||
|
- 📦 **Base45 Encoding** - Compact QR-friendly format (RFC 9285)
|
||||||
|
- 🌐 **100% Client-Side** - No backend, no data transmission
|
||||||
|
|
||||||
## Security Notice
|
## 🔒 Security Notice
|
||||||
|
|
||||||
⚠️ **Your private keys and seed phrases never leave your browser**
|
⚠️ **Your private keys and seed phrases never leave your browser**
|
||||||
|
|
||||||
- This is a static web app with no backend server
|
- Static web app with **no backend server**
|
||||||
- All cryptographic operations run locally in your browser
|
- All cryptographic operations run **locally in your browser**
|
||||||
- No data is transmitted to any server
|
- **No data transmitted** to any server
|
||||||
|
- Camera access requires **HTTPS or localhost**
|
||||||
- Always verify you're on the correct URL before use
|
- Always verify you're on the correct URL before use
|
||||||
|
|
||||||
### For Maximum Security
|
### For Maximum Security
|
||||||
|
|
||||||
For production use with real funds:
|
For production use with real funds:
|
||||||
- Download and run locally
|
- 🏠 Download and run locally (\`bun run dev\`)
|
||||||
- Or use a self-hosted version
|
- 🔐 Use on airgapped device
|
||||||
- Source code: https://github.com/kccleoc/seedpgp-web
|
- 📥 Self-host on your own domain
|
||||||
|
- 🔍 Source code: https://github.com/kccleoc/seedpgp-web (private)
|
||||||
|
|
||||||
## How to Use
|
## 📖 How to Use
|
||||||
|
|
||||||
1. **Encrypt**: Enter your mnemonic + PGP public key → Get QR code
|
### Backup Flow
|
||||||
2. **Decrypt**: Scan QR code + provide private key → Recover mnemonic
|
1. **Enter** your 12/24-word BIP39 mnemonic
|
||||||
|
2. **Add** PGP public key and/or message password (optional)
|
||||||
|
3. **Generate** encrypted QR code
|
||||||
|
4. **Download** or scan QR code for backup
|
||||||
|
|
||||||
## Version
|
### Restore Flow
|
||||||
|
1. **Scan QR Code** using camera or upload image
|
||||||
|
2. **Provide** private key and/or message password
|
||||||
|
3. **Decrypt** to recover your mnemonic
|
||||||
|
|
||||||
Current deployment: **v1.1.0**
|
### QR Scanner Features
|
||||||
|
- 📷 **Camera Mode** - Live scanning with environment camera (iPhone Continuity Camera supported on macOS)
|
||||||
|
- 📁 **Upload Mode** - Scan from saved images or screenshots
|
||||||
|
- ✅ **Auto-validation** - Verifies SEEDPGP1 format before accepting
|
||||||
|
|
||||||
Last updated: 2026-01-28
|
## 🛠 Technical Stack
|
||||||
|
|
||||||
|
- **TypeScript** - Type-safe development
|
||||||
|
- **React 18** - Modern UI framework
|
||||||
|
- **Vite 6** - Lightning-fast build tool
|
||||||
|
- **OpenPGP.js v6** - RFC 4880 compliant encryption
|
||||||
|
- **html5-qrcode** - QR scanning library
|
||||||
|
- **TailwindCSS** - Utility-first styling
|
||||||
|
- **Lucide React** - Beautiful icons
|
||||||
|
|
||||||
|
## 📋 Protocol Format
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
SEEDPGP1:0:ABCD:BASE45DATA
|
||||||
|
|
||||||
|
SEEDPGP1 - Protocol identifier + version
|
||||||
|
0 - Frame number (single frame)
|
||||||
|
ABCD - CRC16-CCITT-FALSE checksum
|
||||||
|
BASE45 - Base45-encoded OpenPGP binary message
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 🔐 Encryption Details
|
||||||
|
|
||||||
|
- **Algorithm**: AES-256 (preferred symmetric cipher)
|
||||||
|
- **Curve**: Curve25519Legacy for modern security
|
||||||
|
- **Key Format**: OpenPGP RFC 4880 compliant
|
||||||
|
- **Error Correction**: QR Level M (15% recovery)
|
||||||
|
- **Integrity**: CRC16-CCITT-FALSE frame validation
|
||||||
|
|
||||||
|
## 📱 Browser Compatibility
|
||||||
|
|
||||||
|
- ✅ Chrome/Edge (latest)
|
||||||
|
- ✅ Safari 16+ (macOS/iOS)
|
||||||
|
- ✅ Firefox (latest)
|
||||||
|
- 📷 Camera requires HTTPS or localhost
|
||||||
|
|
||||||
|
## 📦 Version
|
||||||
|
|
||||||
|
**Current deployment: v1.2.0**
|
||||||
|
|
||||||
|
### Changelog
|
||||||
|
|
||||||
|
#### v1.2.0 (2026-01-29)
|
||||||
|
- ✨ Added QR scanner with camera/upload support
|
||||||
|
- 📥 Added QR code download with auto-naming
|
||||||
|
- 🔧 Split state for backup/restore tabs
|
||||||
|
- 🎨 Improved QR generation quality
|
||||||
|
- 🐛 Fixed Safari camera permissions
|
||||||
|
- 📱 Added Continuity Camera support
|
||||||
|
|
||||||
|
#### v1.1.0 (2026-01-28)
|
||||||
|
- 🎉 Initial public release
|
||||||
|
- 🔐 OpenPGP encryption/decryption
|
||||||
|
- 📱 QR code generation
|
||||||
|
- ✅ BIP39 validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Built with TypeScript, React, Vite, and OpenPGP.js v6
|
**Last updated**: 2026-01-29
|
||||||
|
|
||||||
|
**Built with** ❤️ using TypeScript, React, Vite, and OpenPGP.js
|
||||||
|
|
||||||
|
**License**: Private source code - deployment only
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
VERSION=$1
|
VERSION=$1
|
||||||
@@ -9,23 +10,26 @@ if [ -z "$VERSION" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🔨 Building $VERSION..."
|
echo "🔨 Building $VERSION..."
|
||||||
|
# Remove old build files but keep .git
|
||||||
|
rm -rf dist/assets dist/index.html dist/*.js dist/*.css dist/vite.svg
|
||||||
|
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
echo "📄 Adding README..."
|
echo "📄 Adding README..."
|
||||||
|
if [ -f public/README.md ]; then
|
||||||
cp public/README.md dist/README.md
|
cp public/README.md dist/README.md
|
||||||
|
fi
|
||||||
|
|
||||||
echo "📦 Deploying to GitHub Pages..."
|
echo "📦 Deploying to GitHub Pages..."
|
||||||
cd dist
|
cd dist
|
||||||
|
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Deploy $VERSION" || echo "No changes to commit"
|
git commit -m "Deploy $VERSION" || echo "No changes to commit"
|
||||||
git push
|
git push
|
||||||
|
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
|
echo "✅ Deployed to https://kccleoc.github.io/seedpgp-web-app/"
|
||||||
echo "📖 Repo: https://github.com/kccleoc/seedpgp-web-app"
|
echo ""
|
||||||
echo "🏷️ Don't forget to tag: git tag $VERSION && git push --tags"
|
echo "Tag private repo:"
|
||||||
echo "./scripts/deploy.sh v1.2.0"
|
echo " git tag $VERSION && git push origin --tags"
|
||||||
echo "git tag v1.2.0"
|
|
||||||
echo "git push --tags"
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
16
src/App.tsx
16
src/App.tsx
@@ -19,6 +19,9 @@ 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 type { SeedPgpPlaintext } from './lib/types';
|
||||||
import * as openpgp from 'openpgp';
|
import * as openpgp from 'openpgp';
|
||||||
|
import { StorageIndicator } from './components/StorageIndicator';
|
||||||
|
import { SecurityWarnings } from './components/SecurityWarnings';
|
||||||
|
import { ClipboardTracker } from './components/ClipboardTracker';
|
||||||
|
|
||||||
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
||||||
|
|
||||||
@@ -197,6 +200,7 @@ function App() {
|
|||||||
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
|
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
||||||
|
data-sensitive="BIP39 Mnemonic"
|
||||||
placeholder="Enter your 12 or 24 word seed phrase..."
|
placeholder="Enter your 12 or 24 word seed phrase..."
|
||||||
value={mnemonic}
|
value={mnemonic}
|
||||||
onChange={(e) => setMnemonic(e.target.value)}
|
onChange={(e) => setMnemonic(e.target.value)}
|
||||||
@@ -236,6 +240,7 @@ function App() {
|
|||||||
<PgpKeyInput
|
<PgpKeyInput
|
||||||
label="PGP Private Key (Optional)"
|
label="PGP Private Key (Optional)"
|
||||||
icon={FileKey}
|
icon={FileKey}
|
||||||
|
data-sensitive="PGP Private Key"
|
||||||
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
||||||
value={privateKeyInput}
|
value={privateKeyInput}
|
||||||
onChange={setPrivateKeyInput}
|
onChange={setPrivateKeyInput}
|
||||||
@@ -248,6 +253,7 @@ function App() {
|
|||||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
data-sensitive="Message Password"
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
placeholder="Unlock private key..."
|
placeholder="Unlock private key..."
|
||||||
value={privateKeyPassphrase}
|
value={privateKeyPassphrase}
|
||||||
@@ -428,7 +434,17 @@ function App() {
|
|||||||
onClose={() => setShowQRScanner(false)}
|
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 */}
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
184
src/components/ClipboardTracker.tsx
Normal file
184
src/components/ClipboardTracker.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ClipboardEvent {
|
||||||
|
timestamp: Date;
|
||||||
|
field: string;
|
||||||
|
length: number; // Show length without storing actual content
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClipboardTracker = () => {
|
||||||
|
const [events, setEvents] = useState<ClipboardEvent[]>([]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCopy = (e: ClipboardEvent & Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Get selection to measure length
|
||||||
|
const selection = window.getSelection()?.toString() || '';
|
||||||
|
const length = selection.length;
|
||||||
|
|
||||||
|
if (length === 0) return; // Nothing copied
|
||||||
|
|
||||||
|
// Detect field name
|
||||||
|
let field = 'Unknown field';
|
||||||
|
|
||||||
|
if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') {
|
||||||
|
// Try multiple ways to identify the field
|
||||||
|
field =
|
||||||
|
target.getAttribute('aria-label') ||
|
||||||
|
target.getAttribute('name') ||
|
||||||
|
target.getAttribute('id') ||
|
||||||
|
(target as HTMLInputElement).type ||
|
||||||
|
target.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check parent labels
|
||||||
|
const label = target.closest('label') ||
|
||||||
|
document.querySelector(`label[for="${target.id}"]`);
|
||||||
|
if (label) {
|
||||||
|
field = label.textContent?.trim() || field;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for data-sensitive attribute
|
||||||
|
const sensitiveAttr = target.getAttribute('data-sensitive') ||
|
||||||
|
target.closest('[data-sensitive]')?.getAttribute('data-sensitive');
|
||||||
|
if (sensitiveAttr) {
|
||||||
|
field = sensitiveAttr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if it looks like sensitive data
|
||||||
|
const isSensitive = /mnemonic|seed|key|private|password|secret/i.test(
|
||||||
|
target.className + ' ' + field + ' ' + (target.getAttribute('placeholder') || '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSensitive && field === target.tagName.toLowerCase()) {
|
||||||
|
// Try to guess from placeholder
|
||||||
|
const placeholder = target.getAttribute('placeholder');
|
||||||
|
if (placeholder) {
|
||||||
|
field = placeholder.substring(0, 40) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents(prev => [
|
||||||
|
{ timestamp: new Date(), field, length },
|
||||||
|
...prev.slice(0, 9) // Keep last 10 events
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Auto-expand on first copy
|
||||||
|
if (events.length === 0) {
|
||||||
|
setIsExpanded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('copy', handleCopy as EventListener);
|
||||||
|
return () => document.removeEventListener('copy', handleCopy as EventListener);
|
||||||
|
}, [events.length]);
|
||||||
|
|
||||||
|
const clearClipboard = async () => {
|
||||||
|
try {
|
||||||
|
// Actually clear the system clipboard
|
||||||
|
await navigator.clipboard.writeText('');
|
||||||
|
|
||||||
|
// Clear history
|
||||||
|
setEvents([]);
|
||||||
|
|
||||||
|
// Show success briefly
|
||||||
|
alert('✅ Clipboard cleared and history wiped');
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback for browsers that don't support clipboard API
|
||||||
|
const dummy = document.createElement('textarea');
|
||||||
|
dummy.value = '';
|
||||||
|
document.body.appendChild(dummy);
|
||||||
|
dummy.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(dummy);
|
||||||
|
|
||||||
|
setEvents([]);
|
||||||
|
alert('✅ History cleared (clipboard may require manual clearing)');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-24 right-4 z-50 max-w-sm">
|
||||||
|
<div className={`rounded-lg shadow-lg border-2 transition-all ${events.length > 0
|
||||||
|
? 'bg-orange-50 border-orange-400'
|
||||||
|
: 'bg-gray-50 border-gray-300'
|
||||||
|
}`}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 cursor-pointer flex items-center justify-between rounded-t-lg transition-colors ${events.length > 0 ? 'hover:bg-orange-100' : 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">📋</span>
|
||||||
|
<span className="font-semibold text-sm text-gray-700">Clipboard Activity</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{events.length > 0 && (
|
||||||
|
<span className="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full font-medium">
|
||||||
|
{events.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400 text-sm">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-4 border-t border-gray-300">
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div className="mb-3 bg-orange-100 border border-orange-300 rounded-md p-3 text-xs text-orange-900">
|
||||||
|
<strong>⚠️ Clipboard Warning:</strong> Copied data is accessible to other apps,
|
||||||
|
browser tabs, and extensions. Clear clipboard after use.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{events.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 mb-3 max-h-64 overflow-y-auto">
|
||||||
|
{events.map((event, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-orange-200 rounded-md p-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-orange-900 break-all">
|
||||||
|
{event.field}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-[10px] whitespace-nowrap">
|
||||||
|
{event.timestamp.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-[10px]">
|
||||||
|
Copied {event.length} character{event.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent collapse toggle
|
||||||
|
clearClipboard();
|
||||||
|
}}
|
||||||
|
className="w-full text-xs py-2 px-3 bg-orange-600 hover:bg-orange-700 text-white rounded-md font-medium transition-colors"
|
||||||
|
>
|
||||||
|
🗑️ Clear Clipboard & History
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="text-3xl mb-2">✅</div>
|
||||||
|
<p className="text-xs text-gray-500">No clipboard activity detected</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
81
src/components/SecurityWarnings.tsx
Normal file
81
src/components/SecurityWarnings.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export const SecurityWarnings = () => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 z-50 max-w-sm">
|
||||||
|
<div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-lg">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 cursor-pointer flex items-center justify-between hover:bg-yellow-100 transition-colors rounded-t-lg"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">⚠️</span>
|
||||||
|
<span className="font-semibold text-sm text-yellow-900">Security Limitations</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-yellow-600 text-sm">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-yellow-300 space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🧵"
|
||||||
|
title="JavaScript Strings are Immutable"
|
||||||
|
description="Strings cannot be overwritten in memory. Copies persist until garbage collection runs (timing unpredictable)."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🗑️"
|
||||||
|
title="No Guaranteed Memory Wiping"
|
||||||
|
description="JavaScript has no secure memory clearing. Sensitive data may linger in RAM until GC or browser restart."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="📋"
|
||||||
|
title="Clipboard Exposure"
|
||||||
|
description="Copied data is accessible to other tabs/apps. Browser extensions can read clipboard contents."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="💾"
|
||||||
|
title="Browser Storage Persistence"
|
||||||
|
description="localStorage survives browser restart. sessionStorage survives page refresh. Both readable by any script on this domain."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🔍"
|
||||||
|
title="DevTools Access"
|
||||||
|
description="All app state, memory, and storage visible in browser DevTools. Never use on untrusted devices."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Warning
|
||||||
|
icon="🌐"
|
||||||
|
title="Network Risks (When Online)"
|
||||||
|
description="If hosted online: DNS, HTTPS, CDN, and browser can see usage patterns. Use offline/local for maximum security."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-yellow-300 text-xs text-yellow-800">
|
||||||
|
<strong>Recommendation:</strong> Use this tool on a dedicated offline device.
|
||||||
|
Clear browser data after each use. Never use on shared/public computers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Warning = ({ icon, title, description }: { icon: string; title: string; description: string }) => (
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<span className="text-base flex-shrink-0">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-yellow-900 mb-0.5">{title}</div>
|
||||||
|
<div className="text-yellow-800 leading-relaxed">{description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
150
src/components/StorageIndicator.tsx
Normal file
150
src/components/StorageIndicator.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface StorageItem {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
size: number;
|
||||||
|
isSensitive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageIndicator = () => {
|
||||||
|
const [localItems, setLocalItems] = useState<StorageItem[]>([]);
|
||||||
|
const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password'];
|
||||||
|
|
||||||
|
const isSensitiveKey = (key: string): boolean => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
return SENSITIVE_PATTERNS.some(pattern => lowerKey.includes(pattern));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStorageItems = (storage: Storage): StorageItem[] => {
|
||||||
|
const items: StorageItem[] = [];
|
||||||
|
for (let i = 0; i < storage.length; i++) {
|
||||||
|
const key = storage.key(i);
|
||||||
|
if (key) {
|
||||||
|
const value = storage.getItem(key) || '';
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
value: value.substring(0, 50) + (value.length > 50 ? '...' : ''),
|
||||||
|
size: new Blob([value]).size,
|
||||||
|
isSensitive: isSensitiveKey(key)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => (b.isSensitive ? 1 : 0) - (a.isSensitive ? 1 : 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshStorage = () => {
|
||||||
|
setLocalItems(getStorageItems(localStorage));
|
||||||
|
setSessionItems(getStorageItems(sessionStorage));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshStorage();
|
||||||
|
const interval = setInterval(refreshStorage, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalItems = localItems.length + sessionItems.length;
|
||||||
|
const sensitiveCount = [...localItems, ...sessionItems].filter(i => i.isSensitive).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 max-w-md">
|
||||||
|
<div className={`bg-white rounded-lg shadow-lg border-2 ${sensitiveCount > 0 ? 'border-red-400' : 'border-gray-300'
|
||||||
|
} transition-all duration-200`}>
|
||||||
|
|
||||||
|
{/* Header Bar */}
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 rounded-t-lg cursor-pointer flex items-center justify-between ${sensitiveCount > 0 ? 'bg-red-50' : 'bg-gray-50'
|
||||||
|
} hover:opacity-90 transition-opacity`}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">🗄️</span>
|
||||||
|
<span className="font-semibold text-sm text-gray-700">Storage Monitor</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{sensitiveCount > 0 && (
|
||||||
|
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full font-medium">
|
||||||
|
{sensitiveCount}⚠️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${totalItems > 0 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{totalItems === 0 ? '✓ Empty' : `${totalItems} item${totalItems !== 1 ? 's' : ''}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-sm">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-4 max-h-96 overflow-y-auto">
|
||||||
|
{sensitiveCount > 0 && (
|
||||||
|
<div className="mb-3 bg-yellow-50 border border-yellow-300 rounded-md p-3 text-xs">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-yellow-600 mt-0.5">⚠️</span>
|
||||||
|
<div className="text-yellow-800">
|
||||||
|
<strong>Security Notice:</strong> Sensitive data persists in browser storage
|
||||||
|
(survives refresh/restart). Clear manually if on shared device.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<StorageSection title="localStorage" items={localItems} icon="💾" />
|
||||||
|
<StorageSection title="sessionStorage" items={sessionItems} icon="⏱️" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalItems === 0 && (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<div className="text-4xl mb-2">✅</div>
|
||||||
|
<p className="text-sm text-gray-500">No data in browser storage</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StorageSection = ({ title, items, icon }: { title: string; items: StorageItem[]; icon: string }) => {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-gray-500 mb-2 flex items-center gap-1">
|
||||||
|
<span>{icon}</span>
|
||||||
|
<span className="uppercase">{title}</span>
|
||||||
|
<span className="ml-auto text-gray-400 font-normal">({items.length})</span>
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className={`text-xs rounded-md border p-2 ${item.isSensitive
|
||||||
|
? 'bg-red-50 border-red-300'
|
||||||
|
: 'bg-gray-50 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2 mb-1">
|
||||||
|
<span className={`font-mono font-semibold text-[11px] break-all ${item.isSensitive ? 'text-red-700' : 'text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{item.isSensitive && '🔴 '}{item.key}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-[10px] whitespace-nowrap">{item.size}B</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 font-mono text-[10px] break-all leading-relaxed opacity-70">
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,12 @@ import { base45Encode, base45Decode } from "./base45";
|
|||||||
import { crc16CcittFalse } from "./crc16";
|
import { crc16CcittFalse } from "./crc16";
|
||||||
import type { SeedPgpPlaintext, ParsedSeedPgpFrame } from "./types";
|
import type { SeedPgpPlaintext, ParsedSeedPgpFrame } from "./types";
|
||||||
|
|
||||||
|
// Configure OpenPGP.js (disable warnings)
|
||||||
|
openpgp.config.showComment = false;
|
||||||
|
openpgp.config.showVersion = false;
|
||||||
|
openpgp.config.allowUnauthenticatedMessages = true; // Suppress AES warning
|
||||||
|
openpgp.config.allowUnauthenticatedStream = true; // Suppress stream warning
|
||||||
|
|
||||||
function nonEmptyTrimmed(s?: string): string | undefined {
|
function nonEmptyTrimmed(s?: string): string | undefined {
|
||||||
if (!s) return undefined;
|
if (!s) return undefined;
|
||||||
const t = s.trim();
|
const t = s.trim();
|
||||||
|
|||||||
20
src/main.tsx
20
src/main.tsx
@@ -1,3 +1,23 @@
|
|||||||
|
// Suppress OpenPGP.js AES cipher warnings
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
console.warn = (...args: any[]) => {
|
||||||
|
const msg = args[0]?.toString() || '';
|
||||||
|
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalWarn.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args: any[]) => {
|
||||||
|
const msg = args[0]?.toString() || '';
|
||||||
|
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalError.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|||||||
@@ -3,16 +3,9 @@ import react from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
base: '/seedpgp-web-app/', // Match your repo name
|
base: '/seedpgp-web-app/',
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 600, // Suppress warning
|
outDir: 'dist',
|
||||||
rollupOptions: {
|
emptyOutDir: false,
|
||||||
output: {
|
|
||||||
manualChunks: {
|
|
||||||
'openpgp': ['openpgp'], // Separate chunk for PGP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user