8 Commits

Author SHA1 Message Date
LC mac
573cdce585 add recovery kit 2026-02-21 01:20:38 +08:00
ae4c130fde Create offline_recovery_playbook.md 2026-02-20 23:53:41 +08:00
LC mac
02f58f5ef0 basicSsl() removal ; Commenting CSP in index.html for dev 2026-02-19 23:39:49 +08:00
LC mac
f1b0c0738e polished items from the re-audit report by Claude, add Ubuntu live ISO method to README 2026-02-19 22:58:34 +08:00
LC mac
4da39b7b89 security improvement and bugs fixing; modify makefile 2026-02-18 03:24:05 +08:00
LC mac
127b479f4f restyle the butoon to match theme style 2026-02-15 23:50:54 +08:00
LC mac
0a270a5907 change to mobile layout, reduce spacing 2026-02-14 23:19:35 +08:00
LC mac
3bcb343fe3 docs: update version to v1.4.7 and organize documentation
- Update package.json version to v1.4.7
- Update README.md header to v1.4.7
- Update GEMINI.md version references to v1.4.7
- Update RECOVERY_PLAYBOOK.md version to v1.4.7
- Update SECURITY_AUDIT_REPORT.md version to v1.4.7
- Move documentation files to doc/ directory for better organization
- Add new documentation files: LOCAL_TESTING_GUIDE.md, SERVE.md, TAILS_OFFLINE_PLAYBOOK.md
- Add Makefile and serve.ts for improved development workflow
2026-02-13 23:24:26 +08:00
41 changed files with 57275 additions and 984 deletions

259
Makefile Normal file
View File

@@ -0,0 +1,259 @@
.PHONY: help install build build-offline build-tails serve-local serve-bun audit clean verify-offline verify-tails dev test
help:
@echo "seedpgp-web Makefile - Bun-based build system"
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo " 🚀 QUICK START"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo " Recommended for real use (\$$10K+):"
@echo " make full-build-tails # Build, verify, audit for TailsOS"
@echo " make serve-local # Serve on http://localhost:8000"
@echo ""
@echo " For development:"
@echo " make dev # Hot reload dev server"
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo " 📦 BUILD COMMANDS"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo " make install Install dependencies with Bun"
@echo " make build Build for Cloudflare Pages (absolute paths)"
@echo " make build-offline Build with relative paths (local testing)"
@echo " make build-tails Build for TailsOS (CSP embedded, checksums)"
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo " 🔍 VERIFICATION & TESTING"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo " make verify-tails Verify TailsOS build (CSP, paths, integrity)"
@echo " make verify-offline Verify offline build compatibility"
@echo " make audit Run security audit (network, storage, CSP)"
@echo " make test Run test suite (BIP39, Krux, security)"
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo " 🌐 LOCAL SERVERS"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo " make serve-local Serve dist/ with Python HTTP server (port 8000)"
@echo " make serve-bun Serve dist/ with Bun server (port 8000)"
@echo " make dev Development server with hot reload (port 5173)"
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo " 🔗 PIPELINE COMMANDS"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo " make full-build-tails Clean → build-tails → verify → audit"
@echo " make full-build-offline Clean → build-offline → verify"
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo " 🗑️ MAINTENANCE"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo " make clean Remove dist/, dist-tails/, build cache"
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo " 💡 EXAMPLES"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo " # Full TailsOS production build"
@echo " make full-build-tails && make serve-local"
@echo ""
@echo " # Development with hot reload"
@echo " make dev"
@echo ""
@echo " # Manual verification"
@echo " make build-tails"
@echo " make verify-tails"
@echo " grep 'connect-src' dist-tails/index.html"
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@echo "For more details, see README.md or run specific targets."
# Install dependencies
install:
@echo "📦 Installing dependencies with Bun..."
bun install
# Build for Cloudflare (absolute paths, CSP via _headers)
build:
@echo "🔨 Building for Cloudflare Pages (absolute paths)..."
VITE_BASE_PATH="/" bun run vite build
@echo "✅ Build complete: dist/"
@echo " CSP will be enforced by _headers file"
# Build for offline/local testing (relative paths, no CSP)
build-offline:
@echo "🔨 Building for offline use (relative paths)..."
VITE_BASE_PATH="./" bun run vite build
@echo "✅ Build complete: dist/ (with relative asset paths)"
@echo "⚠️ No CSP embedded - use build-tails for production offline use"
# Build for TailsOS with embedded CSP (relative paths + security hardening)
build-tails:
@echo "🔨 Building for TailsOS (relative paths + embedded CSP)..."
VITE_BASE_PATH="./" bun run vite build
@echo ""
@echo "🔒 Injecting production CSP into index.html (replacing baseline CSP)..."
@perl -i.bak -0777 -pe 's|<meta\s+http-equiv="Content-Security-Policy"[^>]*/>|<meta http-equiv="Content-Security-Policy" content="default-src '"'"'self'"'"'; script-src '"'"'self'"'"' '"'"'unsafe-inline'"'"' '"'"'wasm-unsafe-eval'"'"'; style-src '"'"'self'"'"' '"'"'unsafe-inline'"'"'; img-src '"'"'self'"'"' data: blob:; connect-src '"'"'self'"'"' blob: data:; font-src '"'"'self'"'"'; object-src '"'"'none'"'"'; media-src '"'"'self'"'"' blob:; base-uri '"'"'self'"'"'; form-action '"'"'none'"'"';" data-env="tails">|' dist/index.html
@rm -f dist/index.html.bak
@echo "✅ CSP embedded in dist/index.html"
@echo ""
@echo "📦 Creating TailsOS distribution package..."
@mkdir -p dist-tails
@cp -R dist/* dist-tails/
@echo "# SeedPGP Web - TailsOS Offline Build" > dist-tails/README.txt
@echo "" >> dist-tails/README.txt
@echo "Built: $$(date)" >> dist-tails/README.txt
@echo "" >> dist-tails/README.txt
@echo "Usage Instructions:" >> dist-tails/README.txt
@echo "1. Copy this entire folder to a USB drive" >> dist-tails/README.txt
@echo "2. Boot TailsOS from your primary USB" >> dist-tails/README.txt
@echo "3. Insert this application USB drive" >> dist-tails/README.txt
@echo "4. Open Tor Browser (or regular browser if offline)" >> dist-tails/README.txt
@echo "5. Navigate to: file:///media/amnesia/USBNAME/index.html" >> dist-tails/README.txt
@echo "6. Enable JavaScript if prompted" >> dist-tails/README.txt
@echo "" >> dist-tails/README.txt
@echo "Security Features:" >> dist-tails/README.txt
@echo "- Content Security Policy enforced (no network access)" >> dist-tails/README.txt
@echo "- All assets relative (works offline)" >> dist-tails/README.txt
@echo "- No external dependencies or CDN calls" >> dist-tails/README.txt
@echo "- Session-only crypto keys (destroyed on tab close)" >> dist-tails/README.txt
@echo "" >> dist-tails/README.txt
@echo "SHA-256 Checksums:" >> dist-tails/README.txt
@cd dist-tails && find . -type f -not -name "README.txt" -exec shasum -a 256 {} \; | sort >> README.txt
@echo ""
@echo "✅ TailsOS build complete: dist-tails/"
@echo ""
@echo "Next steps:"
@echo " 1. Verify checksums: make verify-tails"
@echo " 2. Format USB (FAT32): diskutil eraseDisk FAT32 SEEDPGP /dev/diskX"
@echo " 3. Copy: cp -R dist-tails/* /Volumes/SEEDPGP/"
@echo " 4. Eject: diskutil eject /Volumes/SEEDPGP"
@echo " 5. Boot TailsOS and test"
verify-tails:
@echo "1⃣ Checking for CSP in index.html..."
@if grep -q "connect-src.*'self'" dist-tails/index.html; then \
echo "✅ CSP allows local connections only (WASM compatible)"; \
else \
echo "❌ CSP misconfigured"; \
exit 1; \
fi
@echo ""
@# 2. CHECK RELATIVE PATHS
@if grep -q 'src="./' dist-tails/index.html; then \
echo "✅ Relative paths detected (offline compatible)"; \
else \
echo "❌ Absolute paths found"; \
exit 1; \
fi
@echo ""
@# 3. SECURITY NOTE (NOT FAILURE)
@echo "5⃣ Security Note:"
@echo " fetch() references exist in bundle (from openpgp.js)"
@echo " ✓ These are BLOCKED by CSP connect-src 'none' at runtime"
@echo " ✓ Browser will reject all network attempts with CSP violation"
@echo ""
@echo "✅ TailsOS build verification complete"
# Development server (for testing locally)
serve-local:
@echo "🚀 Starting local server at http://localhost:8000"
@echo " Press Ctrl+C to stop"
@if [ ! -d dist ]; then \
echo "❌ dist/ not found. Run 'make build' first"; \
exit 1; \
fi
cd dist && python3 -m http.server 8000
serve-bun:
@echo "🚀 Starting Bun static server at http://127.0.0.1:8000"
@echo " Press Ctrl+C to stop"
@if [ ! -d dist ]; then \
echo "❌ dist/ not found. Run 'make build' first"; \
exit 1; \
fi
bun ./serve.ts
# Run test suite
test:
@echo "🧪 Running test suite..."
bun test
# Security audit - check for network calls and suspicious patterns
audit:
@echo "🔍 Running security audit..."
@echo ""
@echo "Checking for network calls in source..."
@grep -r "fetch\|XMLHttpRequest\|axios" src/ --include="*.ts" --include="*.tsx" --include="*.js" || echo "✅ No explicit network calls found"
@echo ""
@echo "Checking for external resources in build..."
@if [ -d dist ]; then \
grep -r "cloudflare\|googleapis\|cdn\|http:" dist/ || echo "✅ No external URLs in dist/"; \
else \
echo "⚠️ dist/ not found - run 'make build' first"; \
fi
@echo ""
@echo "Checking for persistent storage usage..."
@grep -r "localStorage\|sessionStorage" src/ --include="*.ts" --include="*.tsx" || echo "✅ No persistent storage in crypto paths"
@echo ""
@echo "Checking for eval() or Function() usage..."
@grep -r "eval(\|new Function(" src/ --include="*.ts" --include="*.tsx" || echo "✅ No dynamic code execution"
@echo ""
@echo "✅ Security audit complete"
# Verify offline compatibility
verify-offline:
@echo "🧪 Verifying offline compatibility..."
@echo ""
@if [ ! -d dist ]; then \
echo "❌ dist/ not found. Run 'make build-offline' first"; \
exit 1; \
fi
@echo "Checking dist/ file structure..."
@find dist -type f | wc -l | xargs echo "Total files:"
@echo ""
@echo "Verifying index.html exists and is readable..."
@[ -f dist/index.html ] && echo "✅ index.html found" || (echo "❌ index.html NOT found" && exit 1)
@echo ""
@echo "Checking for asset references in index.html..."
@head -20 dist/index.html | grep -q "assets" && echo "✅ Assets referenced" || echo "⚠️ No assets referenced"
@echo ""
@echo "Checking for relative path usage..."
@grep -q 'src="./' dist/index.html && echo "✅ Relative paths detected" || echo "⚠️ Check asset paths"
@echo ""
@echo "✅ Offline compatibility check complete"
# Clean build artifacts
clean:
@echo "🗑️ Cleaning build artifacts..."
rm -rf dist/
rm -rf dist-tails/
rm -rf .dist/
rm -rf node_modules/.vite/
@echo "✅ Clean complete"
# Full TailsOS pipeline: clean, build, verify, audit
full-build-tails: clean build-tails verify-tails audit
@echo ""
@echo "✅ Full TailsOS build pipeline complete!"
@echo " Ready to copy to USB for TailsOS"
@echo ""
@echo "Package location: dist-tails/"
@echo "Includes: index.html, assets/, and README.txt with checksums"
# Full offline pipeline (less strict than Tails)
full-build-offline: clean build-offline verify-offline audit
@echo ""
@echo "✅ Full offline build pipeline complete!"
@echo " Ready for local testing"
# Quick development setup
dev:
@echo "🚀 Starting Bun dev server..."
bun run dev

1045
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,499 +0,0 @@
# Priority Security Patches for SeedPGP
This document outlines the critical security patches needed for production deployment.
## PATCH 1: Add Content Security Policy (CSP)
**File:** `index.html`
Add this meta tag in the `<head>`:
```html
<meta http-equiv="Content-Security-Policy" content="
default-src 'none';
script-src 'self' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'none';
form-action 'none';
frame-ancestors 'none';
base-uri 'self';
upgrade-insecure-requests;
block-all-mixed-content;
report-uri https://security.example.com/csp-report
" />
```
**Why:** Prevents malicious extensions and XSS attacks from injecting code that steals seeds.
---
## PATCH 2: Encrypt All Seeds in State (Stop Using Plain Strings)
**File:** `src/App.tsx`
**Change From:**
```typescript
const [mnemonic, setMnemonic] = useState('');
const [backupMessagePassword, setBackupMessagePassword] = useState('');
```
**Change To:**
```typescript
// Only store encrypted reference
const [mnemonicEncrypted, setMnemonicEncrypted] = useState<EncryptedBlob | null>(null);
const [passwordEncrypted, setPasswordEncrypted] = useState<EncryptedBlob | null>(null);
// Use wrapper function to decrypt temporarily when needed
async function withMnemonic<T>(
callback: (mnemonic: string) => Promise<T>
): Promise<T | null> {
if (!mnemonicEncrypted) return null;
const decrypted = await decryptBlobToJson<{ value: string }>(mnemonicEncrypted);
try {
return await callback(decrypted.value);
} finally {
// Zero attempt (won't fully work, but good practice)
Object.assign(decrypted, { value: '\0'.repeat(decrypted.value.length) });
}
}
```
**Why:** Sensitive data stored encrypted in React state, not as plaintext. Prevents memory dumps and malware from easily accessing seeds.
---
## PATCH 3: Implement BIP39 Checksum Validation
**File:** `src/lib/bip39.ts`
**Replace:**
```typescript
export function validateBip39Mnemonic(words: string): { valid: boolean; error?: string } {
// ... word count only
return { valid: true };
}
```
**With:**
```typescript
export async function validateBip39Mnemonic(words: string): Promise<{ valid: boolean; error?: string }> {
const normalized = normalizeBip39Mnemonic(words);
const arr = normalized.length ? normalized.split(" ") : [];
const validCounts = new Set([12, 15, 18, 21, 24]);
if (!validCounts.has(arr.length)) {
return {
valid: false,
error: `Invalid word count: ${arr.length}. Must be 12, 15, 18, 21, or 24.`,
};
}
// ✅ NEW: Verify each word is in wordlist and checksum is valid
try {
// This will throw if mnemonic is invalid
await mnemonicToEntropy(normalized);
return { valid: true };
} catch (e) {
return {
valid: false,
error: `Invalid BIP39 mnemonic: ${e instanceof Error ? e.message : 'Unknown error'}`
};
}
}
```
**Usage Update:**
```typescript
// Update all validation calls to use await
const validation = await validateBip39Mnemonic(mnemonic);
if (!validation.valid) {
setError(validation.error);
return;
}
```
**Why:** Prevents users from backing up invalid seeds or corrupted mnemonics.
---
## PATCH 4: Disable Console Output of Sensitive Data
**File:** `src/main.tsx`
**Add at top:**
```typescript
// Disable all console output in production
if (import.meta.env.PROD) {
console.log = () => {};
console.error = () => {};
console.warn = () => {};
console.debug = () => {};
}
```
**File:** `src/lib/krux.ts`
**Remove:**
```typescript
console.log('🔐 KEF Debug:', { label, iterations, version, length: kef.length, base43: kefBase43.slice(0, 50) });
console.error("Krux decryption internal error:", error);
```
**File:** `src/components/QrDisplay.tsx`
**Remove:**
```typescript
console.log('🎨 QrDisplay generating QR for:', value);
console.log(' - Type:', value instanceof Uint8Array ? 'Uint8Array' : typeof value);
console.log(' - Length:', value.length);
console.log(' - Hex:', Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(''));
```
**Why:** Prevents seeds from being recoverable via browser history, crash dumps, or remote debugging.
---
## PATCH 5: Secure Clipboard Access
**File:** `src/App.tsx`
**Replace `copyToClipboard` function:**
```typescript
const copyToClipboard = async (text: string | Uint8Array, fieldName = 'Data') => {
if (isReadOnly) {
setError("Copy to clipboard is disabled in Read-only mode.");
return;
}
const textToCopy = typeof text === 'string' ? text :
Array.from(text).map(b => b.toString(16).padStart(2, '0')).join('');
// Mark when copy started
const copyStartTime = Date.now();
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
// Add warning for sensitive data
if (fieldName.toLowerCase().includes('mnemonic') ||
fieldName.toLowerCase().includes('seed')) {
setClipboardEvents(prev => [
{
timestamp: new Date(),
field: fieldName,
length: textToCopy.length,
willClearIn: 10
},
...prev.slice(0, 9)
]);
// Auto-clear clipboard after 10 seconds
setTimeout(async () => {
try {
// Write garbage to obscure previous content (best effort)
const garbage = crypto.getRandomValues(new Uint8Array(textToCopy.length))
.reduce((s, b) => s + String.fromCharCode(b), '');
await navigator.clipboard.writeText(garbage);
} catch {}
}, 10000);
// Show warning
alert(`⚠️ ${fieldName} copied to clipboard!\n\n✅ Will auto-clear in 10 seconds.\n\n🔒 Recommend: Use QR codes instead for maximum security.`);
}
// Always clear the UI state after a moment
window.setTimeout(() => setCopied(false), 1500);
} catch (err) {
// Fallback for browsers that don't support clipboard API
const ta = document.createElement("textarea");
ta.value = textToCopy;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
};
```
**Update the textarea field:**
```typescript
// When copying mnemonic:
onClick={() => copyToClipboard(mnemonic, 'BIP39 Mnemonic (⚠️ Sensitive)')}
```
**Why:** Automatically clears clipboard content and warns users about clipboard exposure.
---
## PATCH 6: Comprehensive Network Blocking
**File:** `src/App.tsx`
**Replace `handleToggleNetwork` function:**
```typescript
const handleToggleNetwork = () => {
setIsNetworkBlocked(!isNetworkBlocked);
const blockAllNetworks = () => {
console.log('🚫 Network BLOCKED - All external requests disabled');
// Store originals
(window as any).__originalFetch = window.fetch;
(window as any).__originalXHR = window.XMLHttpRequest;
(window as any).__originalWS = window.WebSocket;
(window as any).__originalImage = window.Image;
if (navigator.sendBeacon) {
(window as any).__originalBeacon = navigator.sendBeacon;
}
// 1. Block fetch
window.fetch = (async () =>
Promise.reject(new Error('Network blocked by user'))
) as any;
// 2. Block XMLHttpRequest
window.XMLHttpRequest = new Proxy(XMLHttpRequest, {
construct() {
throw new Error('Network blocked: XMLHttpRequest not allowed');
}
}) as any;
// 3. Block WebSocket
window.WebSocket = new Proxy(WebSocket, {
construct() {
throw new Error('Network blocked: WebSocket not allowed');
}
}) as any;
// 4. Block BeaconAPI
if (navigator.sendBeacon) {
(navigator as any).sendBeacon = () => {
console.error('Network blocked: sendBeacon not allowed');
return false;
};
}
// 5. Block Image src for external resources
const OriginalImage = window.Image;
window.Image = new Proxy(OriginalImage, {
construct(target) {
const img = Reflect.construct(target, []);
const originalSrcSetter = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype, 'src'
)?.set;
Object.defineProperty(img, 'src', {
configurable: true,
set(value) {
if (value && !value.startsWith('data:') && !value.startsWith('blob:')) {
throw new Error(`Network blocked: cannot load external image [${value}]`);
}
originalSrcSetter?.call(this, value);
},
get: Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')?.get
});
return img;
}
}) as any;
// 6. Block Service Workers
if (navigator.serviceWorker) {
(navigator.serviceWorker as any).register = async () => {
throw new Error('Network blocked: Service Workers disabled');
};
}
};
const unblockAllNetworks = () => {
console.log('🌐 Network ACTIVE - All requests allowed');
// Restore everything
if ((window as any).__originalFetch) window.fetch = (window as any).__originalFetch;
if ((window as any).__originalXHR) window.XMLHttpRequest = (window as any).__originalXHR;
if ((window as any).__originalWS) window.WebSocket = (window as any).__originalWS;
if ((window as any).__originalImage) window.Image = (window as any).__originalImage;
if ((window as any).__originalBeacon) navigator.sendBeacon = (window as any).__originalBeacon;
};
if (!isNetworkBlocked) {
blockAllNetworks();
} else {
unblockAllNetworks();
}
};
```
**Why:** Comprehensively blocks all network APIs, not just fetch(), preventing seed exfiltration.
---
## PATCH 7: Validate PGP Keys
**File:** `src/lib/seedpgp.ts`
**Add new function:**
```typescript
export async function validatePGPKey(armoredKey: string): Promise<{
valid: boolean;
error?: string;
fingerprint?: string;
keySize?: number;
expirationDate?: Date;
}> {
try {
const key = await openpgp.readKey({ armoredKey });
// 1. Verify encryption capability
try {
await key.getEncryptionKey();
} catch {
return { valid: false, error: "Key has no encryption subkey" };
}
// 2. Check key expiration
const expirationTime = await key.getExpirationTime();
if (expirationTime && expirationTime < new Date()) {
return { valid: false, error: "Key has expired" };
}
// 3. Check key strength (try to extract key size)
let keySize = 0;
try {
const mainKey = key.primaryKey as any;
if (mainKey.getBitSize) {
keySize = mainKey.getBitSize();
}
if (keySize > 0 && keySize < 2048) {
return { valid: false, error: `Key too small (${keySize} bits). Minimum 2048.` };
}
} catch (e) {
// Unable to determine key size, but continue
}
// 4. Verify primary key can encrypt
const result = await key.verifyPrimaryKey();
if (result.status !== 'valid') {
return { valid: false, error: "Key has invalid signature" };
}
return {
valid: true,
fingerprint: key.getFingerprint().toUpperCase(),
keySize: keySize || undefined,
expirationDate: expirationTime || undefined
};
} catch (e) {
return { valid: false, error: `Failed to parse key: ${e instanceof Error ? e.message : 'Unknown error'}` };
}
}
```
**Use before encrypting:**
```typescript
if (publicKeyInput) {
const validation = await validatePGPKey(publicKeyInput);
if (!validation.valid) {
setError(validation.error);
return;
}
// Show fingerprint to user for verification
setRecipientFpr(validation.fingerprint || '');
}
```
**Why:** Ensures only valid, strong PGP keys are used for encryption.
---
## PATCH 8: Add Key Rotation
**File:** `src/lib/sessionCrypto.ts`
**Add rotation logic:**
```typescript
let sessionKey: CryptoKey | null = null;
let keyCreatedAt = 0;
const KEY_ROTATION_INTERVAL = 5 * 60 * 1000; // 5 minutes
const MAX_KEY_OPERATIONS = 1000; // Rotate after N operations
let keyOperationCount = 0;
export async function getSessionKey(): Promise<CryptoKey> {
const now = Date.now();
const shouldRotate =
!sessionKey ||
(now - keyCreatedAt) > KEY_ROTATION_INTERVAL ||
keyOperationCount > MAX_KEY_OPERATIONS;
if (shouldRotate) {
if (sessionKey) {
// Log key rotation (no sensitive data)
console.debug(`Rotating session key (age: ${now - keyCreatedAt}ms, ops: ${keyOperationCount})`);
destroySessionKey();
}
const key = await window.crypto.subtle.generateKey(
{
name: KEY_ALGORITHM,
length: KEY_LENGTH,
},
false,
['encrypt', 'decrypt'],
);
sessionKey = key;
keyCreatedAt = now;
keyOperationCount = 0;
}
return sessionKey;
}
export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
keyOperationCount++;
// ... rest of function
}
// Auto-clear on visibility change
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
console.debug('Page hidden - clearing session key');
destroySessionKey();
}
});
```
**Why:** Limits the time and operations a single session key is used, reducing risk of key compromise.
---
## Deployment Checklist
- [ ] Add CSP meta tag to index.html
- [ ] Encrypt all sensitive strings in state (use EncryptedBlob)
- [ ] Implement BIP39 checksum validation with await
- [ ] Disable console.log/error/warn in production
- [ ] Update copyToClipboard to auto-clear and warn
- [ ] Implement comprehensive network blocking
- [ ] Add PGP key validation
- [ ] Add session key rotation
- [ ] Run full test suite
- [ ] Test in offline mode (Tails OS)
- [ ] Test with hardware wallets (Krux, Coldcard)
- [ ] Security review of all changes
- [ ] Deploy to staging
- [ ] Final audit
- [ ] Deploy to production
---
**Note:** These patches should be reviewed and tested thoroughly before production deployment. Consider having security auditor review changes before release.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,9 +1,17 @@
/*
# Security Headers
Cloudflare Pages headers for SeedPGP Web
This file must be named _headers at build output root, or in public/_headers,
depending on your deployment setup.
*/
/*
Catch-all for the app
*/
/
/* Security headers */
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' blob: data:; font-src 'self'; object-src 'none'; media-src 'self' blob:; base-uri 'self'; form-action 'none'; frame-ancestors 'none';
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
X-XSS-Protection: 1; mode=block
# Content Security Policy
Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'self'; upgrade-insecure-requests; block-all-mixed-content; worker-src 'self' blob:; script-src-elem 'self' 'unsafe-inline';
Permissions-Policy: camera=(), microphone=(), geolocation=()

View File

@@ -7,11 +7,13 @@
"dependencies": {
"@types/bip32": "^2.0.4",
"@types/bip39": "^3.0.4",
"@types/jszip": "^3.4.1",
"@types/pako": "^2.0.4",
"bip32": "^5.0.0",
"buffer": "^6.0.3",
"html5-qrcode": "^2.3.8",
"jsqr": "^1.4.0",
"jszip": "^3.10.1",
"lucide-react": "^0.462.0",
"openpgp": "^6.3.0",
"pako": "^2.1.0",
@@ -250,6 +252,8 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/jszip": ["@types/jszip@3.4.1", "", { "dependencies": { "jszip": "*" } }, "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A=="],
"@types/node": ["@types/node@25.2.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg=="],
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
@@ -322,6 +326,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -372,6 +378,10 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
@@ -384,6 +394,8 @@
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -394,6 +406,10 @@
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -462,6 +478,8 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="],
@@ -476,6 +494,8 @@
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
@@ -490,16 +510,22 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
@@ -564,6 +590,8 @@
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"jszip/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

23
dist-tails/README.txt Normal file
View File

@@ -0,0 +1,23 @@
# SeedPGP Web - TailsOS Offline Build
Built: Thu 19 Feb 2026 22:31:58 HKT
Usage Instructions:
1. Copy this entire folder to a USB drive
2. Boot TailsOS from your primary USB
3. Insert this application USB drive
4. Open Tor Browser (or regular browser if offline)
5. Navigate to: file:///media/amnesia/USBNAME/index.html
6. Enable JavaScript if prompted
Security Features:
- Content Security Policy enforced (no network access)
- All assets relative (works offline)
- No external dependencies or CDN calls
- Session-only crypto keys (destroyed on tab close)
SHA-256 Checksums:
5cbbcb8adc7acc3b78a3fd31c76d573302705ff5fd714d03f5a2602591197cb5 ./assets/secp256k1-Cao5Swmf.wasm
aab3ea208db02b2cb40902850c203f23159f515288b26ca5a131e1188b4362af ./assets/index-DW74Yc8k.css
c5d6ba57285386d3c4a4e082b831ca24e6e925d7e25a4c38533a10e06c37b238 ./assets/index-Bwz_2nW3.js
c7cd63f8c0a39b0aca861668029aa569597e3b4f9bcd2e40aa274598522e0e8e ./index.html

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

21
dist-tails/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SeedPGP Web</title>
<!-- Baseline CSP for generic builds.
TailsOS builds override this via Makefile (build-tails target). -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' blob: data:; font-src 'self'; object-src 'none'; media-src 'self' blob:; base-uri 'self'; form-action 'none';" data-env="tails">
<script type="module" crossorigin src="./assets/index-Bwz_2nW3.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DW74Yc8k.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -2,7 +2,7 @@
## Project Overview
**SeedPGP v1.4.5**: Client-side BIP39 mnemonic encryption webapp
**SeedPGP v1.4.7**: Client-side BIP39 mnemonic encryption webapp
**Stack**: Bun + Vite + React + TypeScript + OpenPGP.js + Tailwind CSS
**Deploy**: Cloudflare Pages (private repo: `seedpgp-web`)
**Live URL**: <https://seedpgp-web.pages.dev/>
@@ -300,7 +300,7 @@ await window.runSessionCryptoTest()
---
## Current Version: v1.4.5
## Current Version: v1.4.7
**Recent Changes (v1.4.5):**
- Fixed QR Scanner bugs related to camera initialization and race conditions.

View File

@@ -1,10 +1,10 @@
# SeedPGP Security Patches - Implementation Summary
## Overview
## Overview (February 17, 2026)
All critical security patches from the forensic security audit have been successfully implemented into the SeedPGP web application. The application is now protected against seed theft, malware injection, memory exposure, and cryptographic attacks.
## Implementation Status: ✅ COMPLETE
## Implementation Status: ✅ COMPLETE (v1.4.7)
### Patch 1: Content Security Policy (CSP) Headers ✅ COMPLETE
@@ -14,16 +14,7 @@ All critical security patches from the forensic security audit have been success
**Implementation:**
```html
<meta http-equiv="Content-Security-Policy" content="
default-src 'none';
script-src 'self' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'none';
frame-ancestors 'none';
base-uri 'self';
form-action 'none';
"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'none'; font-src 'self'; object-src 'none'; media-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'none';">
```
**Additional Headers:**

244
doc/LOCAL_TESTING_GUIDE.md Normal file
View File

@@ -0,0 +1,244 @@
# Local Testing & Offline Build Guide
## What Changed
**Updated vite.config.ts**
- Changed `base: '/'``base: process.env.VITE_BASE_PATH || './'`
- Assets now load with relative paths: `./assets/` instead of `/assets/`
- This fixes Safari's file:// protocol restrictions for offline use
**Created Makefile** (Bun-based build system)
- `make build-offline` - Build with relative paths for Tails/USB
- `make serve-local` - Test locally on <http://localhost:8000>
- `make audit` - Security scan for network calls
- `make full-build-offline` - Complete pipeline (build + verify + audit)
**Updated TAILS_OFFLINE_PLAYBOOK.md**
- All references changed from `npm``bun`
- Added Makefile integration
- Added local testing instructions
- Added Appendix sections for quick reference
---
## Why file:// Protocol Failed in Safari
```
[Error] Not allowed to load local resource: file:///assets/index-DRV-ClkL.js
```
**Root cause:** Assets referenced as `/assets/` (absolute paths) don't work with `file://` protocol in browsers for security reasons.
**Solution:** Use relative paths `./assets/` which:
- Work with both `file://` on Tails
- Work with `http://` on macOS for testing
- Are included in the vite.config.ts change above
---
## Testing Locally on macOS (Before Tails)
### Step 1: Build with offline configuration
```bash
cd seedpgp-web
make install # Install dependencies if not done
make build-offline # Build with relative paths
```
### Step 2: Serve locally
```bash
make serve-local
# Output: 🚀 Starting local server at http://localhost:8000
```
### Step 3: Test in Safari
- Open Safari
- Go to: `http://localhost:8000`
- Verify:
- All assets load (no errors in console)
- UI displays correctly
- Functionality works
### Step 4: Clean up
```bash
# Stop server: Ctrl+C
# Clean build: make clean
```
---
## Building for Cloudflare vs Offline
### For Tails/Offline (use this for your air-gapped workflow)
```bash
make build-offline
# Builds with: base: './'
# Assets use relative paths
```
### For Cloudflare Pages (production deployment)
```bash
make build
# Builds with: base: '/'
# Assets use absolute paths (correct for web servers)
```
---
## Verification Commands
```bash
# Check assets are using relative paths
head -20 dist/index.html | grep "src=\|href="
# Should show: src="./assets/..." href="./assets/..."
# Run full security pipeline
make full-build-offline
# Just audit for network calls
make audit
```
---
## USB Transfer Workflow
Once local testing passes:
```bash
# 1. Build with offline paths
make build-offline
# 2. Format USB (replace diskX with your USB)
diskutil secureErase freespace 0 /dev/diskX
# 3. Create partition
diskutil partitionDisk /dev/diskX 1 MBR FAT32 SEEDPGP 0b
# 4. Copy all files
cp -R dist/* /Volumes/SEEDPGP/
# 5. Eject
diskutil eject /Volumes/SEEDPGP
# 6. Boot Tails, insert USB, open file:///media/amnesia/SEEDPGP/index.html in Firefox
```
---
## File Structure After Build
```
dist/
├── index.html (references ./assets/...)
├── assets/
│ ├── index-xxx.js (minified app code)
│ ├── index-xxx.css (styles)
│ └── secp256k1-xxx.wasm (crypto library)
└── vite.svg
```
All assets have relative paths in index.html ✅
---
## Why This Works for Offline
| Scenario | Base Path | Works? |
|----------|-----------|--------|
| `file:///media/amnesia/SEEDPGP/index.html` on Tails | `./` | ✅ Yes |
| `http://localhost:8000` on macOS | `./` | ✅ Yes |
| `https://example.com` on Cloudflare | `./` | ✅ Yes (still works) |
| `file://` with absolute paths `/assets/` | `/` | ❌ No (security blocked) |
The relative path solution works everywhere! ✅
---
## Next Steps
1. **Test locally first**
```bash
make build-offline && make serve-local
```
2. **Verify no network calls**
```bash
make audit
```
3. **Prepare USB for Tails**
- Follow the USB Transfer Workflow section above
4. **Boot Tails and test**
- Follow Phase 5-7 in TAILS_OFFLINE_PLAYBOOK.md
5. **Generate seed phrase**
- All offline, no network exposure ✅
---
## Troubleshooting
**"Cannot find module 'bun'"**
```bash
brew install bun
```
**"make: command not found"**
```bash
# macOS should have make pre-installed
# Verify: which make
```
**"Port 8000 already in use"**
```bash
# The serve script will automatically find another port
# Or kill existing process: lsof -ti:8000 | xargs kill -9
```
**Assets still not loading in Safari**
```bash
# Clear Safari cache
# Safari → Settings → Privacy → Remove All Website Data
# Then test again
```
---
## Key Differences from Original Setup
| Aspect | Before | After |
|--------|--------|-------|
| Package Manager | npm | Bun |
| Base Path | `/` (absolute) | `./` (relative) |
| Build Command | `npm run build` | `make build-offline` |
| Local Testing | Couldn't test locally | `make serve-local` |
| File Protocol Support | ❌ Broken in Safari | ✅ Works everywhere |
---
## Files Modified/Created
- ✏️ `vite.config.ts` - Changed base path to relative
- ✨ `Makefile` - New build automation (8 commands)
- 📝 `TAILS_OFFLINE_PLAYBOOK.md` - Updated for Bun + local testing
All three files are ready to use now!

View File

@@ -1,6 +1,6 @@
## SeedPGP Recovery Playbook - Offline Recovery Guide
**Generated:** Feb 3, 2026 | **SeedPGP v1.4.4** | **Frame Format:** `SEEDPGP1:0:CRC16:BASE45_PAYLOAD`
**Generated:** Feb 3, 2026 | **SeedPGP v1.4.7** | **Frame Format:** `SEEDPGP1:0:CRC16:BASE45_PAYLOAD`
***
@@ -415,7 +415,7 @@ print(f"BIP39 Passphrase used: {'YES' if data['pp'] == 1 else 'NO'}")
**Print this playbook on archival paper or metal. Store separately from encrypted backups and private keys.** 🔒
**Last Updated:** February 3, 2026
**SeedPGP Version:** 1.4.4
**SeedPGP Version:** 1.4.7
**Frame Example CRC:** 58B5 ✓
**Test Recovery:** [ ] Completed [ ] Not Tested

View File

@@ -1,13 +1,13 @@
# SeedPGP Web Application - Comprehensive Forensic Security Audit Report
**Audit Date:** February 12, 2026
**Application:** seedpgp-web v1.4.6
**Audit Date:** February 12, 2026 (Patched February 17, 2026)
**Application:** seedpgp-web v1.4.7
**Scope:** Full encryption, key management, and seed handling application
**Severity Levels:** CRITICAL | HIGH | MEDIUM | LOW
---
## Executive Summary
## Executive Summary & Remediation Status
This forensic audit identified **19 actively exploitable security vulnerabilities** across the SeedPGP web application that could result in:
@@ -1802,13 +1802,13 @@ export function diceToBytes(diceRolls: string): Uint8Array {
### Immediate Critical Fixes (Do First)
| Issue | Fix | Effort | Impact |
|-------|-----|--------|--------|
| Add CSP Header | Implement strict CSP in index.html | 30 min | CRITICAL |
| Remove Plaintext Mnemonic State | Encrypt all seeds in state | 4 hours | CRITICAL |
| Add BIP39 Validation | Implement checksum verification | 1 hour | CRITICAL |
| Disable Console Logs | Remove all crypto output from console | 30 min | CRITICAL |
| Restrict Clipboard Access | Add warnings and auto-clear | 1 hour | CRITICAL |
| Issue | Status |
|-------|--------|
| Add CSP Header | **Fixed** |
| Remove Plaintext Mnemonic State | **Fixed** |
| Add BIP39 Validation | **Fixed** |
| Disable Console Logs | **Fixed** |
| Restrict Clipboard Access | **Fixed** |
### High Priority (Next Sprint)
@@ -1929,6 +1929,6 @@ For production use with large sums, recommend: **Krux Device** or **Trezor** har
---
**Report Compiled:** February 12, 2026
**Report Compiled:** February 12, 2026 (Updated: February 17, 2026)
**Audit Conducted By:** Security Forensics Analysis System
**Severity Rating:** CRITICAL - 19 Issues Identified
**Remediation Status:** COMPLETE

0
doc/SECURITY_PATCHES.md Normal file
View File

44
doc/SERVE.md Normal file
View File

@@ -0,0 +1,44 @@
# Serving the built `dist/` folder
This project provides two lightweight ways to serve the static `dist/` folder locally (both are offline):
1) Bun static server (uses `serve.ts`)
```bash
# From project root
bun run serve
# or
bun ./serve.ts
# Open: http://127.0.0.1:8000
```
1) Python HTTP server (zero-deps, portable)
```bash
# From the `dist` folder
cd dist
python3 -m http.server 8000
# Open: http://localhost:8000
```
Convenience via `package.json` scripts:
```bash
# Run Bun server
bun run serve
# Run Python server (from project root)
bun run serve:py
```
Makefile shortcuts:
```bash
make serve-bun # runs Bun server
make serve-local # runs Python http.server
```
Notes:
- The Bun server sets correct `Content-Type` for `.wasm` and other assets.
- Always use HTTP (localhost) rather than opening `file://` to avoid CORS/file restrictions.

View File

@@ -0,0 +1,618 @@
# Tails Offline Air-Gapped Workflow Playbook
## Overview
This playbook provides step-by-step instructions for using seedpgp-web in a secure, air-gapped environment on Tails, eliminating network exposure entirely.
---
## Phase 1: Prerequisites & Preparation
### 1.1 Requirements
- **Machine A (Build Machine)**: macOS with Bun, TypeScript, and Git installed
- **Tails USB**: 8GB+ USB drive with Tails installed (from tails.boum.org)
- **Application USB**: Separate 2GB+ USB drive for seedpgp-web
- **Network**: Initial internet access on Machine A only
### 1.2 Verify Prerequisites on Machine A (macOS with Bun)
```bash
# Verify Bun is installed
bun --version # Should be v1.0+
# Verify TypeScript tools
which tsc
# Verify git
git --version
# Clone repository
cd ~/workspace
git clone <repository-url> seedpgp-web
cd seedpgp-web
```
### 1.3 Security Checklist Before Starting
- [ ] Machine A (macOS) is trusted and malware-free (or at minimum risk)
- [ ] Bun is installed and up-to-date
- [ ] Tails USB is downloaded from official tails.boum.org
- [ ] You have physical access to verify USB connections
- [ ] You understand this is offline-only after transfer to Application USB
---
## Phase 2: Build Application Locally (Machine A)
### 2.1 Clone and Verify Code
```bash
cd ~/workspace
git clone https://github.com/seedpgp/seedpgp-web.git
cd seedpgp-web
git log --oneline -5 # Document the commit hash for reference
```
### 2.2 Install Dependencies with Bun
```bash
# Use Bun for faster installation
bun install
```
### 2.3 Code Audit (CRITICAL)
Before building, audit the source for security issues:
- [ ] Review `src/lib/seedpgp.ts` - main crypto logic
- [ ] Review `src/lib/seedblend.ts` - seed blending algorithm
- [ ] Check `src/lib/bip39.ts` - BIP39 implementation
- [ ] Verify no external API calls in code
- [ ] Run `grep -r "fetch\|axios\|http\|api" src/` to find network calls
- [ ] Confirm all dependencies in `bunfig.toml` and `package.json` are necessary
```bash
# Perform initial audit with Bun
bun run audit # If audit script exists
grep -r "fetch\|axios\|XMLHttpRequest" src/
grep -r "localStorage\|sessionStorage" src/ # Check what data persists
```
### 2.4 Build Production Bundle Using Makefile
```bash
# Using Makefile (recommended)
make build-offline
# Or directly with Bun
bun run build
```
This generates:
- `dist/index.html` - Main HTML file
- `dist/assets/` - Bundled JavaScript, CSS (using relative paths)
- All static assets
### 2.5 Verify Build Output & Test Locally
```bash
# List all generated files
find dist -type f
# Verify no external resource links
grep -r "cloudflare\|googleapis\|cdn\|http:" dist/ || echo "✓ No external URLs found"
# Test locally with Bun's simple HTTP server
bun ./dist/index.html
# Or serve on port 8000
bun --serve --port 8000 ./dist # deprecated: Bun does not provide a built-in static server
# Use the Makefile: `make serve-local` (runs a Python http.server) or run directly:
# cd dist && python3 -m http.server 8000
# Then open http://localhost:8000 in Safari
```
**Why not file://?**: Safari and Firefox restrict loading local assets via `file://` protocol for security. Using a local HTTP server bypasses this while keeping everything offline.
---
## Phase 3: Prepare Application USB (Machine A - macOS with Bun)
### 3.1 Format USB Drive
```bash
# List USB drives
diskutil list
# Replace diskX with your Application USB (e.g., disk2)
diskutil secureErase freespace 0 /dev/diskX
# Create new partition
diskutil partitionDisk /dev/diskX 1 MBR FAT32 SEEDPGP 0b
```
### 3.2 Copy Built Files to USB
```bash
# Mount should happen automatically, verify:
ls /Volumes/SEEDPGP
# Copy entire dist folder built with make build-offline
cp -R dist/* /Volumes/SEEDPGP/
# Verify copy completed
ls /Volumes/SEEDPGP/
ls /Volumes/SEEDPGP/assets/
# (Optional) Generate integrity hash for verification on Tails
sha256sum dist/* > /Volumes/SEEDPGP/INTEGRITY.sha256
```
### 3.3 Verify USB Contents
```bash
# Ensure index.html exists and is readable
cat /Volumes/SEEDPGP/index.html | head -20
# Check file count matches
echo "Source files:" && find dist -type f | wc -l
echo "USB files:" && find /Volumes/SEEDPGP -type f | wc -l
# Check assets are properly included
find /Volumes/SEEDPGP -type d -name "assets" && echo "✅ Assets folder present"
# Verify no external URLs in assets
grep -r "http:" /Volumes/SEEDPGP/ && echo "⚠️ Warning: HTTP URLs found" || echo "✅ No external URLs"
```
### 3.4 Eject USB Safely
```bash
diskutil eject /Volumes/SEEDPGP
```
---
## Phase 4: Boot Tails & Prepare Environment
### 4.1 Boot Tails from Tails USB
- Power off machine
- Insert Tails USB
- Power on and boot from USB (Cmd+Option during boot on Mac)
- Select "Start Tails"
- **DO NOT connect to network** (decline "Connect to Tor" if prompted)
### 4.2 Insert Application USB
- Once Tails is running, insert Application USB
- Tails should auto-mount it to `/media/amnesia/<random-name>/`
### 4.3 Verify Files Accessible & Start HTTP Server
```bash
# Open terminal in Tails
ls /media/amnesia/
# Should see your Application USB mount
# Navigate to application
cd /media/amnesia/SEEDPGP/
# Verify files are present
ls -la
cat index.html | head -5
# Start local HTTP server (runs completely offline)
python3 -m http.server 8080 &
# Output: Serving HTTP on 0.0.0.0 port 8080
# Verify server is running
curl http://localhost:8080/index.html | head -5
```
**Note:** Python3 is pre-installed on Tails. The http.server runs completely offline—no internet access required, just localhost.
---
## Phase 5: Run Application on Tails
### 5.1 Open Application in Browser via Local HTTP Server
**Why HTTP instead of file://?**
- HTTP is more reliable than `file://` protocol
- Eliminates browser security restrictions
- Identical to local testing on macOS
- Still completely offline (no network exposure)
**Steps:**
1. **In Terminal (where you started the server from Phase 4.3):**
- Verify server is still running: `ps aux | grep http.server`
- Should show: `python3 -m http.server 8080`
- If stopped, restart: `cd /media/amnesia/SEEDPGP && python3 -m http.server 8080 &`
2. **Open Firefox:**
- Click Firefox icon on desktop
- In address bar, type: `http://localhost:8080`
- Press Enter
3. **Verify application loaded:**
- Page should load completely
- All UI elements visible
- No errors in browser console (F12 → Console tab)
### 5.2 Verify Offline Functionality
- [ ] Page loads completely
- [ ] All UI elements are visible
- [ ] No error messages in browser console (F12)
- [ ] Images/assets display correctly
- [ ] No network requests are visible in Network tab (F12)
### 5.3 Test Application Features
**Basic Functionality:**
```
- [ ] Can generate new seed phrase
- [ ] Can input existing seed phrase
- [ ] Can encrypt seed phrase
- [ ] Can generate PGP key
- [ ] QR codes generate correctly
```
**Entropy Sources (all should work offline):**
- [ ] Dice entropy input works
- [ ] User mouse/keyboard entropy captures
- [ ] Random.org is NOT accessible (verify UI indicates offline mode)
- [ ] Audio entropy can be recorded
### 5.4 Generate Your Seed Phrase
1. Navigate to main application
2. Choose entropy source (Dice, Audio, or Interaction)
3. Follow prompts to generate entropy
4. Review generated 12/24-word seed phrase
5. **Write down on paper** (do NOT screenshot, use only pen & paper)
6. Verify BIP39 validation passes
---
## Phase 6: Secure Storage & Export
### 6.1 Export Encrypted Backup (Optional)
If you want to save encrypted backup to USB:
1. Use application's export feature
2. Encrypt with strong passphrase
3. Save to Application USB
4. **Do NOT save to host machine**
### 6.2 Generate PGP Key (Optional)
1. Use seedpgp-web to generate PGP key
2. Export private key (encrypted)
3. Save encrypted to USB if desired
4. **Passphrase should be memorable but not written**
### 6.3 Verify No Leaks
In Firefox Developer Tools (F12):
- **Network tab**: Should show only `localhost:8080` requests (all local)
- **Application/Storage**: Check nothing persistent was written
- **Console**: No fetch/XHR errors to external sites
**To verify server is local-only:**
```bash
# In terminal, check network connections
sudo netstat -tulpn | grep 8080
# Should show: tcp 0 0 127.0.0.1:8080 (LISTEN only on localhost)
# NOT on 0.0.0.0 or external interface
```
---
## Phase 7: Shutdown & Cleanup
### 7.1 Stop HTTP Server & Secure Shutdown
```bash
# Stop the http.server gracefully
killall python3
# Or find the PID and kill it
ps aux | grep http.server
kill -9 <PID> # Replace <PID> with actual process ID
# Verify it stopped
ps aux | grep http.server # Should show nothing
# Then power off Tails completely
sudo poweroff
# You can also:
# - Select "Power Off" from Tails menu
# - Or simply close/restart the laptop
```
**Important:** Killing the server ensures no background processes remain before shutdown.
### 7.2 Physical USB Handling
- [ ] Eject Application USB physically
- [ ] Eject Tails USB physically
- [ ] Store both in secure location
- **Tails memory is volatile** - all session data gone after power-off
### 7.3 Host Machine Cleanup (Machine A)
```bash
# Remove build artifacts if desired (optional)
rm -rf dist/
# Clear sensitive files from shell history
history -c
# Optionally wipe Machine A's work directory
rm -rf ~/workspace/seedpgp-web/
```
---
## Phase 8: Verification & Best Practices
### 8.1 Before Production Use - Full Test Run
**Test on macOS with Bun first:**
```bash
cd seedpgp-web
bun install
make build-offline # Build with relative paths
make serve-local # Serve on http://localhost:8000
# Open Safari: http://localhost:8000
# Verify: all assets load, no console errors, no network requests
```
1. Complete Phases 1-7 with test run on Tails
2. Verify seed phrase generation works reliably
3. Test entropy sources work offline
4. Confirm PGP key generation (if using)
5. Verify export/backup functionality
### 8.2 Security Best Practices
- [ ] **Air-gap is primary defense**: No network = no exfiltration
- [ ] **Tails is ephemeral**: Always boot fresh, always clean shutdown
- [ ] **Paper backups**: Write seed phrase with pen/paper only
- [ ] **Multiple USBs**: Keep Tails and Application USB separate
- [ ] **Verify hash**: Optional - generate hash of `dist/` folder to verify integrity on future builds
### 8.3 Future Seed Generation
Repeat these steps for each new seed phrase:
1. **Boot Tails** from Tails USB (network disconnected)
2. **Insert Application USB** when Tails is running
3. **Start HTTP server:**
```bash
cd /media/amnesia/SEEDPGP
python3 -m http.server 8080 &
```
4. **Open Firefox** → `http://localhost:8080`
5. **Generate seed phrase** (choose entropy source)
6. **Write on paper** (pen & paper only, no screenshots)
7. **Stop server and shutdown:**
```bash
killall python3
sudo poweroff
```
---
## Troubleshooting
### Issue: Application USB Not Mounting on Tails
**Solution**:
```bash
# Check if recognized
sudo lsblk
# Manual mount
sudo mkdir -p /media/usb
sudo mount /dev/sdX1 /media/usb
ls /media/usb
```
### Issue: Black Screen / Firefox Won't Start
**Solution**:
- Let Tails fully boot (may take 2-3 minutes)
- Try manually starting Firefox from Applications menu
- Check memory requirements (Tails recommends 2GB+ RAM)
### Issue: Assets Not Loading (Broken Images/Styling)
**Solution**:
```bash
# Verify file structure on USB
ls -la /media/amnesia/SEEDPGP/assets/
# Check permissions
chmod -R 755 /media/amnesia/SEEDPGP/
```
### Issue: Browser Console Shows Errors
**Solution**:
- Check if `index.html` references external URLs
- Verify `vite.config.ts` doesn't have external dependencies
- Review network tab - should show only `localhost:8080` requests
### Issue: Can't Access <http://localhost:8080>
**Solution**:
```bash
# Verify http.server is running
ps aux | grep http.server
# If not running, restart it
cd /media/amnesia/SEEDPGP
python3 -m http.server 8080 &
# If port 8080 is in use, try another port
python3 -m http.server 8081 &
# Then access http://localhost:8081
```
### Issue: "Connection refused" in Firefox
**Solution**:
```bash
# Check if port is listening
sudo netstat -tulpn | grep 8080
# If not, the server stopped. Restart it:
cd /media/amnesia/SEEDPGP
python3 -m http.server 8080 &
# Wait a few seconds and refresh Firefox (Cmd+R or Ctrl+R)
```
---
## Security Checklist Summary
Before each use:
- [ ] Using Tails booted from USB (never host OS)
- [ ] Application USB inserted (separate from Tails USB)
- [ ] Network disconnected or Tor disabled
- [ ] HTTP server started: `python3 -m http.server 8080` from USB
- [ ] Accessing <http://localhost:8080> (not file://)
- [ ] Firefox console shows no external requests
- [ ] All entropy sources working offline
- [ ] Seed phrase written on paper only
- [ ] HTTP server stopped before shutdown
- [ ] USB ejected after use
- [ ] Tails powered off completely
---
## Appendix A: Makefile Commands Quick Reference
All build commands are available via Makefile on Machine A:
```bash
make help # Show all available commands
make install # Install Bun dependencies
make build-offline # Build with relative paths (for Tails/offline)
make serve-local # Test locally on http://localhost:8000
make audit # Security audit for network calls
make verify-offline # Verify offline compatibility
make full-build-offline # Complete pipeline: clean → build → verify → audit
make clean # Remove dist/ folder
```
**Example workflow:**
```bash
cd seedpgp-web
make install
make audit # Security check
make full-build-offline # Build and verify
# Copy to USB when ready
```
---
## Appendix B: Local Testing on macOS Before Tails
**Why test locally first?**
- Catch build issues early
- Verify all assets load correctly
- Confirm no network requests
- Validation before USB transfer
**Steps:**
```bash
cd seedpgp-web
make build-offline # Build with relative paths
make serve-local # Start local server
# Open Safari: http://localhost:8000
# Test functionality, then Ctrl+C to stop
```
When served locally on <http://localhost:8000>, assets load correctly. On Tails via <http://localhost:8080>, the same relative paths work seamlessly.
---
## Appendix C: Why HTTP Server Instead of file:// Protocol?
### The Problem with file:// Protocol
Opening `file:///path/to/index.html` directly has limitations:
- **Browser security restrictions** - Some features may be blocked or behave unexpectedly
- **Asset loading issues** - Sporadic failures with relative/absolute paths
- **localStorage limitations** - Storage APIs may not work reliably
- **CORS restrictions** - Even local files face CORS-like restrictions on some browsers
- **Debugging difficulty** - Hard to distinguish app issues from browser security issues
### Why http.server Solves This
Python's `http.server` module:
1. **Mimics production environment** - Behaves like a real web server
2. **Completely offline** - Server runs only on localhost (127.0.0.1:8080)
3. **No internet required** - No connection to external servers
4. **Browser compatible** - Works reliably across Firefox, Safari, Chrome
5. **Identical to macOS testing** - Same mechanism for both platforms
6. **Simple & portable** - Python3 comes pre-installed on Tails
**Verify the server is local-only:**
```bash
sudo netstat -tulpn | grep 8080
# Output should show: 127.0.0.1:8080 LISTEN (localhost only)
# NOT 0.0.0.0:8080 (would indicate public access)
```
This ensures your seedpgp-web app runs in a standard HTTP environment without any network exposure. ✅
---
## Additional Resources
- **Tails Documentation**: <https://tails.boum.org/doc/>
- **seedpgp-web Security Audit**: See SECURITY_AUDIT_REPORT.md
- **BIP39 Standard**: <https://github.com/trezor/python-mnemonic>
- **Air-gap Best Practices**: <https://en.wikipedia.org/wiki/Air_gap_(networking)>
- **Bun Documentation**: <https://bun.sh/docs>
- **Python HTTP Server**: <https://docs.python.org/3/library/http.server.html>
---
## Version History
- **v2.1** - February 13, 2026 - Updated to use Python http.server instead of file:// for reliability
- **v2.0** - February 13, 2026 - Updated for Bun, Makefile, and offline compatibility
- **v1.0** - February 13, 2026 - Initial playbook creation

View File

@@ -0,0 +1,658 @@
# 🆘 SeedPGP Offline Recovery Playbook
**EMERGENCY SEED RECOVERY WITHOUT THE SEEDPGP WEB APP**
---
## 📋 What This Document Is For
You created an encrypted backup of your cryptocurrency seed phrase using SeedPGP. This document explains **how to decrypt that backup if the SeedPGP web app is no longer available** (website down, GitHub deleted, domain expired, etc.).
**Print this document and store it with your encrypted QR backup.**
---
## 🎯 Quick Reference: What You Need
Depending on how you encrypted your backup, you need:
| Encryption Method | What You Need to Decrypt |
|-------------------|--------------------------|
| **Password-only** | Password + this playbook + any computer with GPG |
| **PGP Public Key** | Private key + private key passphrase + this playbook + any computer with GPG |
| **Krux KEF format** | Passphrase + Python 3 + this playbook |
---
## 🔍 Step 1: Identify Your Backup Format
Look at your encrypted backup. The format determines which recovery method to use:
### **Format A: SeedPGP Standard (PGP)**
Your QR code or text starts with:
```
SEEDPGP1:0:A1B2:CDEFG...
```
**OR** your backup is a PGP armored message:
```
-----BEGIN PGP MESSAGE-----
hQEMA...
-----END PGP MESSAGE-----
```
**Use Method 1: GPG Command-Line Recovery** (see below)
---
### **Format B: Krux KEF**
Your QR code or text starts with:
```
KEF:1234+ABCD...
```
**OR** it's a Base43-encoded string using only these characters:
```
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$%*+-./:
```
**Use Method 2: Python Krux Decryption** (see below)
---
### **Format C: Plain SeedQR** (NOT ENCRYPTED)
Your QR is all digits (48 or 96 digits for 12/24 words):
```
0216007100420461...
```
**OR** all hex (32 or 64 hex characters):
```
1a2b3c4d5e6f...
```
**Use Method 3: SeedQR Decoder** (see below)
---
## 📖 Method 1: GPG Command-Line Recovery (PGP Format)
### **What You Need:**
- ✅ Your encrypted backup (QR scan result or PGP armored text)
- ✅ Your password (if password-encrypted)
- ✅ Your PGP private key + passphrase (if key-encrypted)
- ✅ A computer with GPG installed (Linux/Mac: pre-installed, Windows: download Gpg4win)
---
### **Step 1A: Extract the PGP Message**
If your backup is a `SEEDPGP1:...` QR string, you need to decode it first:
```bash
# Your QR scan result looks like:
# SEEDPGP1:0:A1B2:BASE45_ENCODED_DATA_HERE
# Extract just the Base45 part (everything after the third colon)
echo "SEEDPGP1:0:A1B2:BASE45DATA" | cut -d: -f4- > base45.txt
# Decode Base45 to binary PGP (requires Python script - see Appendix A)
python3 decode_base45.py base45.txt > encrypted.pgp
```
If your backup is already a PGP armored message (`-----BEGIN PGP MESSAGE-----`), save it to a file:
```bash
cat > encrypted.asc << 'EOF'
-----BEGIN PGP MESSAGE-----
hQEMA...
-----END PGP MESSAGE-----
EOF
```
---
### **Step 1B: Decrypt with GPG**
**If encrypted with PASSWORD only:**
```bash
# Decrypt using password
gpg --decrypt encrypted.pgp
# OR if you have the armored version:
gpg --decrypt encrypted.asc
# GPG will prompt: "Enter passphrase:"
# Type your password exactly as you created it
# Output will be JSON like: {"v":1,"t":"bip39","w":"word1 word2 word3...","l":"en","pp":0}
```
**If encrypted with PGP PUBLIC KEY:**
```bash
# First, import your private key (if not already in your GPG keyring)
gpg --import my-private-key.asc
# Decrypt
gpg --decrypt encrypted.pgp
# GPG will prompt for your PRIVATE KEY PASSPHRASE (not the backup password)
# Output will be JSON like: {"v":1,"t":"bip39","w":"word1 word2 word3...","l":"en","pp":0}
```
---
### **Step 1C: Extract Your Seed Phrase**
The decrypted output is JSON format:
```json
{"v":1,"t":"bip39","w":"abandon ability able about above...","l":"en","pp":0}
```
**Your seed phrase is the value of the `"w"` field.**
Extract it:
```bash
# If output is in a file:
cat decrypted.json | grep -o '"w":"[^"]*"' | cut -d'"' -f4
# Or use Python:
python3 -c 'import json; print(json.load(open("decrypted.json"))["w"])'
```
**Write down your seed phrase immediately on paper.**
---
### **JSON Field Meanings:**
| Field | Meaning | Example Value |
|-------|---------|---------------|
| `v` | Format version | `1` |
| `t` | Mnemonic type | `"bip39"` |
| `w` | **Your seed phrase (words)** | `"abandon ability able..."` |
| `l` | Language | `"en"` (English) |
| `pp` | BIP39 passphrase used? | `0` (no) or `1` (yes) |
| `fpr` | Recipient PGP fingerprints | `["ABC123..."]` (optional) |
**If `pp` is `1`:** You used a BIP39 passphrase in addition to your seed words. You need BOTH to restore your wallet.
---
## 🐍 Method 2: Python Krux Decryption (KEF Format)
### **What You Need:**
- ✅ Your Krux KEF backup (QR scan result starting with `KEF:` or Base43 string)
- ✅ Your passphrase
- ✅ A computer with Python 3
---
### **Step 2A: Prepare the Decryption Script**
Save this Python script as `decrypt_krux.py`:
```python
#!/usr/bin/env python3
"""
Krux KEF (Krux Encryption Format) Offline Decryption Tool
For emergency recovery when SeedPGP is unavailable
"""
import hashlib
import hmac
from getpass import getpass
# Base43 alphabet (Krux standard)
B43_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$%*+-./:"
def base43_decode(s):
"""Decode Base43 string to bytes"""
n = 0
for c in s:
n = n * 43 + B43_CHARS.index(c)
byte_len = (n.bit_length() + 7) // 8
return n.to_bytes(byte_len, 'big')
def unwrap_kef(kef_bytes):
"""Extract label, version, iterations, and payload from KEF envelope"""
if len(kef_bytes) < 5:
raise ValueError("Invalid KEF: too short")
label_len = kef_bytes[0]
if label_len > 252 or len(kef_bytes) < 1 + label_len + 4:
raise ValueError("Invalid KEF: malformed header")
label = kef_bytes[1:1+label_len].decode('utf-8')
version = kef_bytes[1+label_len]
iter_bytes = kef_bytes[2+label_len:5+label_len]
iterations = int.from_bytes(iter_bytes, 'big')
if iterations <= 10000:
iterations *= 10000
payload = kef_bytes[5+label_len:]
return label, version, iterations, payload
def pbkdf2_hmac_sha256(password, salt, iterations, dklen=32):
"""PBKDF2-HMAC-SHA256 key derivation"""
return hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations, dklen)
def aes_gcm_decrypt(key, iv, ciphertext, tag):
"""AES-GCM decryption using cryptography library"""
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(key)
return aesgcm.decrypt(iv, ciphertext + tag, None)
except ImportError:
print("ERROR: 'cryptography' library not found.")
print("Install with: pip3 install cryptography")
raise
def entropy_to_mnemonic(entropy_bytes):
"""Convert entropy to BIP39 mnemonic (requires bip39 library)"""
try:
from mnemonic import Mnemonic
mnemo = Mnemonic("english")
return mnemo.to_mnemonic(entropy_bytes)
except ImportError:
print("ERROR: 'mnemonic' library not found.")
print("Install with: pip3 install mnemonic")
raise
def decrypt_krux(kef_data, passphrase):
"""Main decryption function"""
# Step 1: Decode from Base43 or hex
if kef_data.startswith('KEF:'):
kef_data = kef_data[4:].strip()
try:
kef_bytes = base43_decode(kef_data)
except:
try:
kef_bytes = bytes.fromhex(kef_data)
except:
raise ValueError("Invalid KEF format: not Base43 or hex")
# Step 2: Unwrap KEF envelope
label, version, iterations, payload = unwrap_kef(kef_bytes)
if version not in [20, 21]:
raise ValueError(f"Unsupported KEF version: {version}")
print(f"KEF Label: {label}")
print(f"Version: {version} (AES-GCM{' +compress' if version == 21 else ''})")
print(f"Iterations: {iterations}")
# Step 3: Derive key from passphrase
salt = label.encode('utf-8')
key = pbkdf2_hmac_sha256(passphrase, salt, iterations, 32)
# Step 4: Extract IV, ciphertext, and tag
iv = payload[:12]
ciphertext = payload[12:-4]
tag = payload[-4:]
# Step 5: Decrypt
decrypted = aes_gcm_decrypt(key, iv, ciphertext, tag)
# Step 6: Decompress if needed
if version == 21:
import zlib
decrypted = zlib.decompress(decrypted)
# Step 7: Convert to mnemonic
mnemonic = entropy_to_mnemonic(decrypted)
return mnemonic
if __name__ == "__main__":
print("=" * 60)
print("KRUX KEF EMERGENCY DECRYPTION TOOL")
print("=" * 60)
print()
kef_input = input("Paste your KEF backup (Base43 or hex): ").strip()
passphrase = getpass("Enter passphrase: ")
try:
mnemonic = decrypt_krux(kef_input, passphrase)
print()
print("=" * 60)
print("SUCCESS! Your seed phrase:")
print("=" * 60)
print(mnemonic)
print("=" * 60)
print()
print("⚠️ Write this down on paper immediately!")
print("⚠️ Never save to disk or take a screenshot!")
except Exception as e:
print()
print(f"ERROR: {e}")
print()
print("Common issues:")
print("1. Wrong passphrase")
print("2. Missing Python libraries (run: pip3 install cryptography mnemonic)")
print("3. Corrupted KEF data")
```
---
### **Step 2B: Install Dependencies**
```bash
# Install required Python libraries
pip3 install cryptography mnemonic
# Or on Ubuntu/Debian:
sudo apt install python3-pip
pip3 install cryptography mnemonic
```
---
### **Step 2C: Run the Decryption**
```bash
# Make script executable
chmod +x decrypt_krux.py
# Run it
python3 decrypt_krux.py
# It will prompt:
# "Paste your KEF backup (Base43 or hex):"
# → Paste your full QR scan result or KEF string
# "Enter passphrase:"
# → Type your passphrase (won't show on screen)
# Output:
# SUCCESS! Your seed phrase:
# abandon ability able about above absent absorb...
```
**Write down your seed phrase immediately.**
---
## 📱 Method 3: SeedQR Decoder (Unencrypted Format)
### **What You Need:**
- ✅ Your SeedQR backup (all digits or hex)
- ✅ BIP39 wordlist (see Appendix B)
- ✅ Optional: Python script (see below)
---
### **Step 3A: Identify SeedQR Type**
**Standard SeedQR (all digits):**
- 48 digits = 12-word seed
- 96 digits = 24-word seed
- Each 4 digits = one BIP39 word index (0000-2047)
**Compact SeedQR (hex):**
- 32 hex chars = 12-word seed
- 64 hex chars = 24-word seed
- Raw entropy encoded as hexadecimal
---
### **Step 3B: Manual Decoding (Standard SeedQR)**
**Example:** `0216007100420461...` (48 digits for 12 words)
1. Split into 4-digit chunks: `0216`, `0071`, `0042`, `0461`, ...
2. Each chunk is a word index (0-2047)
3. Look up each index in the BIP39 wordlist (Appendix B)
```
0216 → word #216 = "brick"
0071 → word #71 = "appear"
0042 → word #42 = "advise"
0461 → word #461 = "dove"
...
```
**Your seed phrase is the words in order.**
---
### **Step 3C: Python Script (Standard SeedQR)**
Save as `decode_seedqr.py`:
```python
#!/usr/bin/env python3
"""SeedQR to BIP39 Mnemonic Decoder"""
# BIP39 English wordlist (2048 words)
# Download from: https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt
# Or see Appendix B of this document
def load_wordlist(filepath='bip39_wordlist.txt'):
with open(filepath) as f:
return [line.strip() for line in f]
def decode_standard_seedqr(qr_digits):
"""Decode standard SeedQR (4-digit word indices)"""
if len(qr_digits) not in [48, 96]:
raise ValueError(f"Invalid length: {len(qr_digits)} (expected 48 or 96)")
wordlist = load_wordlist()
mnemonic = []
for i in range(0, len(qr_digits), 4):
index = int(qr_digits[i:i+4])
if index >= 2048:
raise ValueError(f"Invalid word index: {index} (max 2047)")
mnemonic.append(wordlist[index])
return ' '.join(mnemonic)
def decode_compact_seedqr(qr_hex):
"""Decode compact SeedQR (hex-encoded entropy)"""
if len(qr_hex) not in [32, 64]:
raise ValueError(f"Invalid hex length: {len(qr_hex)} (expected 32 or 64)")
try:
from mnemonic import Mnemonic
mnemo = Mnemonic("english")
entropy = bytes.fromhex(qr_hex)
return mnemo.to_mnemonic(entropy)
except ImportError:
print("ERROR: 'mnemonic' library required for compact SeedQR")
print("Install: pip3 install mnemonic")
raise
if __name__ == "__main__":
qr_data = input("Paste your SeedQR data: ").strip()
if qr_data.isdigit():
mnemonic = decode_standard_seedqr(qr_data)
elif all(c in '0123456789abcdefABCDEF' for c in qr_data):
mnemonic = decode_compact_seedqr(qr_data)
else:
print("ERROR: Not a valid SeedQR (must be all digits or all hex)")
exit(1)
print()
print("Your seed phrase:")
print(mnemonic)
print()
```
---
## 📚 Appendix A: Base45 Decoder
If you need to decode SeedPGP's Base45 format manually, save this as `decode_base45.py`:
```python
#!/usr/bin/env python3
"""Base45 Decoder for SeedPGP Recovery"""
B45_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
def base45_decode(s):
"""Decode Base45 string to bytes"""
result = []
i = 0
while i < len(s):
if i + 2 < len(s):
# Process 3 characters → 2 bytes
c = B45_CHARS.index(s[i])
d = B45_CHARS.index(s[i+1])
e = B45_CHARS.index(s[i+2])
x = c + d * 45 + e * 45 * 45
result.append(x // 256)
result.append(x % 256)
i += 3
else:
# Process 2 characters → 1 byte
c = B45_CHARS.index(s[i])
d = B45_CHARS.index(s[i+1])
x = c + d * 45
if x > 255:
raise ValueError("Invalid Base45 encoding")
result.append(x)
i += 2
return bytes(result)
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
# Read from file
with open(sys.argv[1]) as f:
b45_input = f.read().strip()
else:
# Read from stdin
b45_input = input("Paste Base45 data: ").strip()
try:
decoded = base45_decode(b45_input)
sys.stdout.buffer.write(decoded)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
exit(1)
```
---
## 📚 Appendix B: BIP39 English Wordlist
**Download the official wordlist:**
```bash
wget https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt
```
**Or manually:** The BIP39 English wordlist contains exactly 2048 words, indexed 0-2047:
```
0000: abandon
0001: ability
0002: able
0003: about
...
2045: zero
2046: zone
2047: zoo
```
**Full wordlist:** https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt
---
## 🛡️ Security Recommendations
### **When Recovering Your Seed:**
1.**Use an air-gapped computer** (TailsOS or Ubuntu Live USB)
2.**Disconnect from internet** before decrypting
3.**Write seed on paper immediately** after decryption
4.**Never screenshot or save to disk**
5.**Verify your seed** by importing to a test wallet (small amount first)
6.**Destroy digital traces** after recovery (shutdown amnesic OS)
### **Storage Best Practices:**
- 📄 **Print this document** and store with your encrypted backup
- 🔐 Store backup + recovery instructions in different locations
- 💾 Keep a copy of Python scripts on offline USB
- 📦 Include a copy of the BIP39 wordlist (offline reference)
- 🗂️ Label everything clearly: "SEEDPGP BACKUP + RECOVERY GUIDE - DO NOT LOSE"
---
## 🆘 Emergency Contact
**If you're stuck:**
1. Search GitHub for "seedpgp-web" (project may still exist)
2. Check Internet Archive: https://web.archive.org/
3. Ask in Bitcoin/crypto forums (describe format, don't share actual data!)
4. Hire a professional cryptocurrency recovery service (last resort)
**Never share:**
- ❌ Your encrypted backup data with strangers
- ❌ Your password or passphrase
- ❌ Your PGP private key
- ❌ Your decrypted seed phrase
---
## ✅ Recovery Checklist
Before attempting recovery, verify you have:
- [ ] This printed playbook
- [ ] Your encrypted backup (QR code or text file)
- [ ] Your password/passphrase written down
- [ ] Your PGP private key (if used) + passphrase
- [ ] An air-gapped computer (TailsOS/Ubuntu Live recommended)
- [ ] GPG installed (for PGP decryption)
- [ ] Python 3 + libraries (for Krux/SeedQR decryption)
- [ ] BIP39 wordlist (for manual SeedQR decoding)
- [ ] Paper and pen (to write recovered seed)
**If missing any item above, DO NOT PROCEED. Secure it first.**
---
## 📅 Recommended Practice Schedule
**Every 6 months:**
1. Test that you can still decrypt your backup
2. Verify the recovery tools still work
3. Update this playbook if formats change
4. Check that your passwords/keys are still accessible
**Test with a dummy backup first!** Create a test seed, encrypt it, then practice recovery.
---
**Document Version:** 1.0
**Last Updated:** February 2026
**Compatible with:** SeedPGP v1.4.7+
---
**🔒 KEEP THIS DOCUMENT SAFE AND ACCESSIBLE 🔒**
Your encrypted backup is worthless without the ability to decrypt it.
Print this. Store it with your backup. Test it regularly.
---

View File

@@ -3,11 +3,29 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>SeedPGP v__APP_VERSION__</title>
<!-- Content Security Policy: Prevent XSS, malicious extensions, and external script injection -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%230d0d0d'/><text x='50' y='65' font-family='Arial' font-size='82' font-weight='bold' text-anchor='middle' fill='%23f7931a'>₿</text><circle cx='50' cy='50' r='38' fill='none' stroke='white' stroke-width='6' opacity='0.7'/></svg>">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SeedPGP Web</title>
<!-- Baseline CSP for generic builds.
TailsOS builds override this via Makefile (build-tails target).
Commented out for development to avoid CSP issues with WebAssembly.
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
connect-src 'self' blob: data:;
font-src 'self';
object-src 'none';
media-src 'self' blob:;
base-uri 'self';
form-action 'none';
"
/>
-->
</head>
<body>
@@ -15,4 +33,4 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View File

@@ -9,16 +9,20 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "bun test",
"test:integration": "bun test src/integration.test.ts"
"test:integration": "bun test src/integration.test.ts",
"serve": "bun ./serve.ts",
"serve:py": "cd dist && python3 -m http.server 8000"
},
"dependencies": {
"@types/bip32": "^2.0.4",
"@types/bip39": "^3.0.4",
"@types/jszip": "^3.4.1",
"@types/pako": "^2.0.4",
"bip32": "^5.0.0",
"buffer": "^6.0.3",
"html5-qrcode": "^2.3.8",
"jsqr": "^1.4.0",
"jszip": "^3.10.1",
"lucide-react": "^0.462.0",
"openpgp": "^6.3.0",
"pako": "^2.1.0",

57
serve.ts Normal file
View File

@@ -0,0 +1,57 @@
// Lightweight static file server using Bun
// Run with: bun ./serve.ts
import { extname } from 'path'
const DIST = new URL('./dist/', import.meta.url).pathname
function contentType(path: string) {
const ext = extname(path).toLowerCase()
switch (ext) {
case '.html': return 'text/html; charset=utf-8'
case '.js': return 'application/javascript; charset=utf-8'
case '.css': return 'text/css; charset=utf-8'
case '.wasm': return 'application/wasm'
case '.svg': return 'image/svg+xml'
case '.json': return 'application/json'
case '.png': return 'image/png'
case '.jpg': case '.jpeg': return 'image/jpeg'
case '.txt': return 'text/plain; charset=utf-8'
default: return 'application/octet-stream'
}
}
Bun.serve({
hostname: '127.0.0.1',
port: 8000,
fetch(request) {
try {
const url = new URL(request.url)
let pathname = decodeURIComponent(url.pathname)
if (pathname === '/' || pathname === '') pathname = '/index.html'
// prevent path traversal
const safePath = new URL('.' + pathname, 'file:' + DIST).pathname
// Ensure file is inside dist
if (!safePath.startsWith(DIST)) {
return new Response('Not Found', { status: 404 })
}
try {
const file = Bun.file(safePath)
const headers = new Headers()
headers.set('Content-Type', contentType(safePath))
// Localhost only; still set a permissive origin for local dev
headers.set('Access-Control-Allow-Origin', 'http://localhost')
return new Response(file.stream(), { status: 200, headers })
} catch (e) {
return new Response('Not Found', { status: 404 })
}
} catch (err) {
return new Response('Internal Server Error', { status: 500 })
}
}
})
console.log('Bun static server running at http://127.0.0.1:8000 serving ./dist')

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { QrCode, RefreshCw, CheckCircle2, Lock, AlertCircle, Camera, Dices, Mic, Unlock, EyeOff, FileKey, Info } from 'lucide-react';
import { QrCode, RefreshCw, CheckCircle2, Lock, AlertCircle, Camera, Dices, Mic, Unlock, EyeOff, FileKey, Info, Package } from 'lucide-react';
import { PgpKeyInput } from './components/PgpKeyInput';
import { QrDisplay } from './components/QrDisplay';
import QRScanner from './components/QRScanner';
@@ -18,6 +18,8 @@ import CameraEntropy from './components/CameraEntropy';
import DiceEntropy from './components/DiceEntropy';
import RandomOrgEntropy from './components/RandomOrgEntropy';
import { InteractionEntropy } from './lib/interactionEntropy';
import { TestRecovery } from './components/TestRecovery';
import { generateRecoveryKit } from './lib/recoveryKit';
import AudioEntropy from './AudioEntropy';
@@ -35,7 +37,7 @@ interface ClipboardEvent {
}
function App() {
const [activeTab, setActiveTab] = useState<'create' | 'backup' | 'restore' | 'seedblender'>('create');
const [activeTab, setActiveTab] = useState<'create' | 'backup' | 'restore' | 'seedblender' | 'test-recovery'>('create');
const [mnemonic, setMnemonic] = useState('');
const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
@@ -76,7 +78,7 @@ function App() {
const [blenderResetKey, setBlenderResetKey] = useState(0);
// Network blocking state
const [isNetworkBlocked, setIsNetworkBlocked] = useState(false);
const [isNetworkBlocked, setIsNetworkBlocked] = useState(true);
// Entropy generation states
const [entropySource, setEntropySource] = useState<'camera' | 'dice' | 'audio' | 'randomorg' | null>(null);
@@ -120,6 +122,16 @@ function App() {
return () => clearInterval(interval);
}, []);
useEffect(() => {
blockAllNetworks();
// setIsNetworkBlocked(true); // already set by default state
}, []);
useEffect(() => {
blockAllNetworks();
// setIsNetworkBlocked(true); // already set by default state
}, []);
// Cleanup session key on component unmount
@@ -542,9 +554,18 @@ function App() {
// 6. Block Service Workers
if (navigator.serviceWorker) {
// Block new registrations
(navigator.serviceWorker as any).register = async () => {
throw new Error('Network blocked: Service Workers disabled');
};
// Unregister any existing service workers (defense-in-depth)
navigator.serviceWorker
.getRegistrations()
.then(regs => regs.forEach(reg => reg.unregister()))
.catch(() => {
// Ignore errors; SWs are defense-in-depth only.
});
}
};
@@ -567,7 +588,7 @@ function App() {
}
};
const handleRequestTabChange = (newTab: 'create' | 'backup' | 'restore' | 'seedblender') => {
const handleRequestTabChange = (newTab: 'create' | 'backup' | 'restore' | 'seedblender' | 'test-recovery') => {
// Allow free navigation - no warnings
// User can manually reset Seed Blender with "Reset All" button
setActiveTab(newTab);
@@ -627,6 +648,50 @@ function App() {
setShowQRScanner(false);
}, []);
// Handle download recovery kit
const handleDownloadRecoveryKit = async () => {
if (!qrPayload) {
setError('No backup available to export');
return;
}
try {
setLoading(true);
setError('');
// Get QR image as data URL from canvas
const qrCanvas = document.querySelector('canvas');
const qrImageDataUrl = qrCanvas?.toDataURL('image/png');
// Determine encryption method
const encryptionMethod = publicKeyInput && backupMessagePassword ? 'both'
: publicKeyInput ? 'publickey'
: 'password';
const kitBlob = await generateRecoveryKit({
encryptedData: qrPayload,
encryptionMode: encryptionMode,
encryptionMethod: encryptionMethod,
fingerprint: recipientFpr,
qrImageDataUrl,
});
// Trigger download
const url = URL.createObjectURL(kitBlob);
const a = document.createElement('a');
a.href = url;
a.download = `seedpgp-recovery-kit-${new Date().toISOString().split('T')[0]}.zip`;
a.click();
URL.revokeObjectURL(url);
alert('✅ Recovery kit downloaded! Store this ZIP file safely.');
} catch (err: any) {
setError(`Failed to generate recovery kit: ${err.message}`);
} finally {
setLoading(false);
}
};
@@ -661,7 +726,7 @@ function App() {
onResetAll={handleResetAll}
/>
<main className="w-full px-4 py-3">
<div className="bg-[#1a1a2e] rounded-xl border-2 border-[#00f0ff]/30 shadow-[0_0_30px_rgba(0,240,255,0.3)] p-8">
<div className="bg-[#1a1a2e] rounded-xl border-2 border-[#00f0ff]/30 shadow-[0_0_30px_rgba(0,240,255,0.3)] p-0">
<div className="p-6 md:p-8 space-y-6">
{/* Error Display */}
{error && (
@@ -875,17 +940,19 @@ function App() {
Send Generated Seed To
</label>
<div className="grid grid-cols-2 gap-3 max-w-sm mx-auto">
<label className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'backup' ? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]' : 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'}`}>
<label className={`p-3 md:p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'backup' ? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]' : 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'}`}>
<input type="radio" name="destination" value="backup" checked={seedDestination === 'backup'} onChange={(e) => setSeedDestination(e.target.value as 'backup' | 'seedblender')} className="hidden" />
<div className="text-center space-y-1">
<div className={`text-sm font-bold ${seedDestination === 'backup' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}>📦 Backup</div>
<div className="flex flex-col items-center justify-center gap-1 md:gap-2">
<Lock className={`w-5 h-5 md:w-6 md:h-6 transition-colors ${seedDestination === 'backup' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`} />
<div className={`text-xs md:text-sm font-bold ${seedDestination === 'backup' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}>Backup</div>
<p className="text-[10px] text-[#6ef3f7]">Encrypt immediately</p>
</div>
</label>
<label className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'seedblender' ? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]' : 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'}`}>
<label className={`p-3 md:p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'seedblender' ? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]' : 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'}`}>
<input type="radio" name="destination" value="seedblender" checked={seedDestination === 'seedblender'} onChange={(e) => setSeedDestination(e.target.value as 'backup' | 'seedblender')} className="hidden" />
<div className="text-center space-y-1">
<div className={`text-sm font-bold ${seedDestination === 'seedblender' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}>🎨 Seed Blender</div>
<div className="flex flex-col items-center justify-center gap-1 md:gap-2">
<Dices className={`w-5 h-5 md:w-6 md:h-6 transition-colors ${seedDestination === 'seedblender' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`} />
<div className={`text-xs md:text-sm font-bold ${seedDestination === 'seedblender' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}>Seed Blender</div>
<p className="text-[10px] text-[#6ef3f7]">Use for XOR blending</p>
</div>
</label>
@@ -893,8 +960,12 @@ function App() {
</div>
{/* Send Button */}
<button onClick={handleSendToDestination} className="w-full py-3 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-xl font-bold uppercase hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] transition-all">
Send to {seedDestination === 'backup' ? 'Backup' : 'Seed Blender'}
<button
onClick={handleSendToDestination}
className="w-full py-2.5 bg-[#1a1a2e] border-2 border-[#ff006e] text-[#00f0ff] text-xs md:text-sm rounded-lg font-bold uppercase tracking-wide hover:shadow-[0_0_25px_rgba(255,0,110,0.7)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-[0_0_15px_rgba(255,0,110,0.5)]"
style={{ textShadow: '0 0 8px rgba(0,240,255,0.7)' }}
>
Continue to {seedDestination === 'backup' ? 'Backup' : 'Seed Blender'}
</button>
<button onClick={() => { setGeneratedSeed(''); setEntropySource(null); setEntropyStats(null); }} className="w-full py-2 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] rounded-lg text-sm font-bold hover:bg-[#00f0ff]/20 transition-all">
@@ -1039,6 +1110,10 @@ function App() {
onSeedReceived={() => setSeedForBlender('')}
/>
</div>
<div className={activeTab === 'test-recovery' ? 'block' : 'hidden'}>
<TestRecovery />
</div>
</div>
{/* Security Panel */}
@@ -1190,8 +1265,28 @@ function App() {
{qrPayload && activeTab === 'backup' && (
<div className="pt-6 border-t border-[#00f0ff]/20 space-y-6 animate-in fade-in slide-in-from-bottom-4">
<div className={isReadOnly ? 'blur-lg' : ''}>
<QrDisplay value={qrPayload} />
<QrDisplay value={qrPayload} encryptionMode={encryptionMode} fingerprint={recipientFpr} />
</div>
{/* Download Recovery Kit Button */}
<div className="space-y-2">
<button
onClick={handleDownloadRecoveryKit}
disabled={!qrPayload || loading || isReadOnly}
className="w-full py-3 bg-gradient-to-r from-[#00f0ff] to-[#00c8ff] text-[#0a0a0f] rounded-xl font-bold text-sm uppercase tracking-wider hover:shadow-[0_0_30px_rgba(0,240,255,0.5)] transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Package size={20} />
)}
{loading ? 'Generating...' : '📦 Download Recovery Kit'}
</button>
<p className="text-xs text-[#6ef3f7] text-center">
Contains encrypted backup, recovery scripts, instructions, and BIP39 wordlist
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { Mic, X, CheckCircle2 } from 'lucide-react';
import { InteractionEntropy } from './lib/interactionEntropy';
import { entropyToMnemonic } from './lib/seedblend';
interface AudioStats {
sampleRate: number;
@@ -58,7 +59,7 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
if (scriptProcessorRef.current) {
(scriptProcessorRef.current as any).onaudioprocess = null;
try { scriptProcessorRef.current.disconnect(); } catch {}
try { scriptProcessorRef.current.disconnect(); } catch { }
scriptProcessorRef.current = null;
}
@@ -67,28 +68,32 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
const ctx = audioContextRef.current;
audioContextRef.current = null;
if (ctx && ctx.state !== 'closed') {
try { await ctx.close(); } catch {}
try { await ctx.close(); } catch { }
}
};
const requestMicrophoneAccess = async () => {
try {
console.log('🎤 Requesting microphone access...');
const requestMicrophoneAccess = async () => {
try {
if (import.meta.env.DEV) {
console.log('🎤 Requesting microphone access...');
}
// Clean up any existing audio context first
await teardownAudio();
// Clean up any existing audio context first
await teardownAudio();
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
sampleRate: { ideal: 44100 }, // Safari prefers this
channelCount: 1,
},
});
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
sampleRate: { ideal: 44100 }, // Safari prefers this
channelCount: 1,
},
});
console.log('✅ Microphone access granted');
if (import.meta.env.DEV) {
console.log('✅ Microphone access granted');
}
// Set up Web Audio API
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
@@ -117,47 +122,53 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
// Safari fallback: ScriptProcessor gets RAW mic PCM
try {
const scriptProcessor = (audioContext as any).createScriptProcessor(1024, 1, 1);
scriptProcessor.onaudioprocess = (event: AudioProcessingEvent) => {
const inputBuffer = event.inputBuffer.getChannelData(0); // RAW MIC DATA!
// Append for entropy
rawAudioDataRef.current.push(new Float32Array(inputBuffer));
// Calc RMS from raw data
let sum = 0;
for (let i = 0; i < inputBuffer.length; i++) {
sum += inputBuffer[i] * inputBuffer[i];
}
const rawRms = Math.sqrt(sum / inputBuffer.length);
// Update state via postMessage (React-safe)
if (Math.random() < 0.1) { // Throttle
setAudioLevel(Math.min(rawRms * 2000, 100));
}
// Deterministic logging every 30 frames
if (frameCounterRef.current++ % 30 === 0) {
console.log('🎙️ RAW mic RMS:', rawRms.toFixed(4), 'Sample:', inputBuffer.slice(0,5));
if (frameCounterRef.current++ % 30 === 0 && import.meta.env.DEV) {
console.log('🎙️ RAW mic RMS:', rawRms.toFixed(4), 'Sample:', inputBuffer.slice(0, 5));
}
};
// ScriptProcessor branch also pulled
source.connect(scriptProcessor);
scriptProcessor.connect(silentGain); // pull it via the same sink path
scriptProcessorRef.current = scriptProcessor;
console.log('✅ ScriptProcessor active (Safari fallback)');
if (import.meta.env.DEV) {
console.log('✅ ScriptProcessor active (Safari fallback)');
}
} catch (e) {
console.log('⚠️ ScriptProcessor not supported');
if (import.meta.env.DEV) {
console.log('⚠️ ScriptProcessor not supported');
}
}
console.log('🎧 Pipeline primed:', {
sampleRate: audioContext.sampleRate,
state: audioContext.state,
fftSize: analyser.fftSize,
channels: analyser.channelCount,
});
if (import.meta.env.DEV) {
console.log('🎧 Pipeline primed:', {
sampleRate: audioContext.sampleRate,
state: audioContext.state,
fftSize: analyser.fftSize,
channels: analyser.channelCount,
});
}
audioContextRef.current = audioContext;
analyserRef.current = analyser;
@@ -166,20 +177,26 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
// Resume context
if (audioContext.state === 'suspended') {
await audioContext.resume();
console.log('▶️ Audio context resumed:', audioContext.state);
if (import.meta.env.DEV) {
console.log('▶️ Audio context resumed:', audioContext.state);
}
}
// Give pipeline 300ms to fill buffer
setTimeout(() => {
if (analyserRef.current) {
console.log('▶️ Starting analysis after buffer fill');
if (import.meta.env.DEV) {
console.log('▶️ Starting analysis after buffer fill');
}
startAudioAnalysis();
setStep('capture');
}
}, 300);
} catch (err: any) {
console.error('❌ Microphone error:', err);
if (import.meta.env.DEV) {
console.error('❌ Microphone error:', err);
}
setError(`Microphone access denied: ${err.message}`);
setTimeout(() => onCancel(), 2000);
}
@@ -187,11 +204,15 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
const startAudioAnalysis = () => {
if (!analyserRef.current) {
console.error('❌ No analyser');
if (import.meta.env.DEV) {
console.error('❌ No analyser');
}
return;
}
console.log('✅ Analysis loop started');
if (import.meta.env.DEV) {
console.log('✅ Analysis loop started');
}
const analyze = () => {
if (!analyserRef.current) return;
@@ -230,18 +251,22 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
// CLAMP
const clampedLevel = Math.min(Math.max(finalLevel, 0), 100);
// Log first few + random
if (!audioLevelLoggedRef.current) {
audioLevelLoggedRef.current = true;
console.log('📊 First frame:', {
rms: rms.toFixed(4),
level: level.toFixed(1),
timeSample: timeData.slice(0, 5),
freqSample: freqData.slice(0, 5)
});
if (import.meta.env.DEV) {
console.log('📊 First frame:', {
rms: rms.toFixed(4),
level: level.toFixed(1),
timeSample: timeData.slice(0, 5),
freqSample: freqData.slice(0, 5)
});
}
} else if (Math.random() < 0.03) {
console.log('🎵 Level:', clampedLevel.toFixed(1), 'RMS:', rms.toFixed(4));
if (import.meta.env.DEV) {
console.log('🎵 Level:', clampedLevel.toFixed(1), 'RMS:', rms.toFixed(4));
}
}
setAudioLevel(clampedLevel);
@@ -256,7 +281,9 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
// Auto-start analysis when analyser is ready
useEffect(() => {
if (analyserRef.current && step === 'capture' && !animationRef.current) {
console.log('🎬 useEffect: Starting audio analysis');
if (import.meta.env.DEV) {
console.log('🎬 useEffect: Starting audio analysis');
}
startAudioAnalysis();
}
}, [analyserRef.current, step]);
@@ -265,13 +292,17 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
// Ensure audio context is running
if (audioContextRef.current && audioContextRef.current.state === 'suspended') {
await audioContextRef.current.resume();
console.log('▶️ Audio context resumed on capture');
if (import.meta.env.DEV) {
console.log('▶️ Audio context resumed on capture');
}
}
setStep('processing');
setCaptureProgress(0);
console.log('🎙️ Capturing audio entropy...');
if (import.meta.env.DEV) {
console.log('🎙️ Capturing audio entropy...');
}
// Capture 3 seconds of audio data
const captureDuration = 3000; // 3 seconds
@@ -288,23 +319,27 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
if (analyserRef.current) {
const bufferLength = analyserRef.current!.frequencyBinCount;
const timeData = new Float32Array(bufferLength);
analyserRef.current!.getFloatTimeDomainData(timeData);
// Store Float32Array directly (no conversion needed)
audioDataRef.current.push(new Float32Array(timeData));
}
setCaptureProgress(((i + 1) / totalSamples) * 100);
}
// Use raw audio data if available (from ScriptProcessor)
if (rawAudioDataRef.current.length > 0) {
console.log('✅ Using raw audio data from ScriptProcessor:', rawAudioDataRef.current.length, 'samples');
if (import.meta.env.DEV) {
console.log('✅ Using raw audio data from ScriptProcessor:', rawAudioDataRef.current.length, 'samples');
}
audioDataRef.current = rawAudioDataRef.current.slice(-totalSamples); // Use most recent samples
}
console.log('✅ Audio captured:', audioDataRef.current.length, 'samples');
if (import.meta.env.DEV) {
console.log('✅ Audio captured:', audioDataRef.current.length, 'samples');
}
// Analyze captured audio
const audioStats = await analyzeAudioEntropy();
@@ -416,11 +451,9 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
const data = encoder.encode(combined);
const hash = await crypto.subtle.digest('SHA-256', data);
const { entropyToMnemonic } = await import('bip39');
const entropyLength = wordCount === 12 ? 16 : 32;
const finalEntropy = new Uint8Array(hash).slice(0, entropyLength);
const entropyHex = Buffer.from(finalEntropy).toString('hex');
return entropyToMnemonic(entropyHex);
return entropyToMnemonic(finalEntropy);
};
useEffect(() => {
@@ -676,19 +709,19 @@ const AudioEntropy: React.FC<AudioEntropyProps> = ({
Continue with this Seed
</button>
<button
onClick={() => {
setStep('permission');
setStats(null);
setGeneratedMnemonic('');
setAudioLevel(0);
audioDataRef.current = [];
audioLevelLoggedRef.current = false;
}}
className="w-full py-2 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] rounded-lg text-sm font-bold hover:bg-[#00f0ff20] transition-all"
>
Capture Again
</button>
<button
onClick={() => {
setStep('permission');
setStats(null);
setGeneratedMnemonic('');
setAudioLevel(0);
audioDataRef.current = [];
audioLevelLoggedRef.current = false;
}}
className="w-full py-2 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] rounded-lg text-sm font-bold hover:bg-[#00f0ff20] transition-all"
>
Capture Again
</button>
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { Camera, X, AlertCircle, CheckCircle2 } from 'lucide-react';
import { InteractionEntropy } from '../lib/interactionEntropy';
import { entropyToMnemonic } from '../lib/seedblend';
interface EntropyStats {
shannon: number;
@@ -382,14 +383,10 @@ const CameraEntropy: React.FC<CameraEntropyProps> = ({
const hash = await crypto.subtle.digest('SHA-256', data);
// Use bip39 to generate mnemonic from the collected entropy hash
const { entropyToMnemonic } = await import('bip39');
const entropyLength = wordCount === 12 ? 16 : 32;
const finalEntropy = new Uint8Array(hash).slice(0, entropyLength);
// The bip39 library expects a hex string or a Buffer.
const entropyHex = Buffer.from(finalEntropy).toString('hex');
return entropyToMnemonic(entropyHex);
return entropyToMnemonic(finalEntropy);
};
useEffect(() => {

View File

@@ -154,20 +154,20 @@ const DiceEntropy: React.FC<DiceEntropyProps> = ({
{/* INPUT FORM - Show only when stats are NOT shown */}
{!stats && !processing && (
<>
<div className="p-4 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-3">
<div className="p-3 md:p-4 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-3">
<div className="flex items-center gap-2"><Dices size={20} className="text-[#00f0ff]" /><h3 className="text-sm font-bold text-[#00f0ff] uppercase">Dice Roll Entropy</h3></div>
<div className="space-y-2 text-xs text-[#6ef3f7]">
<p className="font-bold text-[#00f0ff]">Instructions:</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li>Roll a 6-sided die at least 99 times</li>
<li>Enter each result (1-6) in order</li>
<li>No spaces needed (e.g., 163452...)</li>
<li>Spaces are ignored (e.g., 163452...)</li>
<li>Pattern validation enabled</li>
</ul>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest">Enter Dice Rolls</label>
<textarea value={rolls} onChange={(e) => { setRolls(e.target.value.replace(/[^1-6\s]/g, '')); setError(''); }} placeholder="99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-xs text-[#00f0ff] placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all resize-none" />
<textarea value={rolls} onChange={(e) => { setRolls(e.target.value.replace(/[^1-6\s]/g, '')); setError(''); }} placeholder="99+ dice rolls (e.g., 16345...)" className="w-full h-28 md:h-32 p-2 md:p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-xs text-[#00f0ff] placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all resize-none" />
<p className="text-[10px] text-[#6ef3f7]">Current: {rolls.replace(/\s/g, '').length} rolls {rolls.replace(/\s/g, '').length >= 99 && ' ✓'}</p>
</div>
{error && (<div className="flex items-start gap-2 p-3 bg-[#0a0a0f] border border-[#ff006e] rounded-lg"><AlertCircle size={16} className="text-[#ff006e] shrink-0 mt-0.5" /><p className="text-xs text-[#ff006e]">{error}</p></div>)}

View File

@@ -24,8 +24,8 @@ interface HeaderProps {
sessionItems: StorageItem[];
events: ClipboardEvent[];
onOpenClipboardModal: () => void;
activeTab: 'create' | 'backup' | 'restore' | 'seedblender';
onRequestTabChange: (tab: 'create' | 'backup' | 'restore' | 'seedblender') => void;
activeTab: 'create' | 'backup' | 'restore' | 'seedblender' | 'test-recovery';
onRequestTabChange: (tab: 'create' | 'backup' | 'restore' | 'seedblender' | 'test-recovery') => void;
appVersion: string;
isNetworkBlocked: boolean;
onToggleNetwork: () => void;
@@ -113,7 +113,7 @@ const Header: React.FC<HeaderProps> = ({
</div>
{/* ROW 3: Navigation Tabs */}
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-5 gap-2">
<button
className={`py-2 rounded-lg font-medium text-xs whitespace-nowrap transition-all ${activeTab === 'create'
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
@@ -157,6 +157,17 @@ const Header: React.FC<HeaderProps> = ({
>
Blender
</button>
<button
className={`py-2 rounded-lg font-medium text-xs whitespace-nowrap transition-all ${activeTab === 'test-recovery'
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
: 'bg-[#0a0a0f] text-[#9d84b7] border-2 border-[#00f0ff30] hover:text-[#6ef3f7] hover:border-[#00f0ff50]'
}`}
style={activeTab === 'test-recovery' ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}
onClick={() => onRequestTabChange('test-recovery')}
>
🧪 Test
</button>
</div>
</div>
</header>

View File

@@ -4,9 +4,11 @@ import QRCode from 'qrcode';
interface QrDisplayProps {
value: string | Uint8Array;
encryptionMode?: 'pgp' | 'krux' | 'seedqr';
fingerprint?: string;
}
export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
export const QrDisplay: React.FC<QrDisplayProps> = ({ value, encryptionMode = 'pgp', fingerprint }) => {
const [dataUrl, setDataUrl] = useState('');
const [debugInfo, setDebugInfo] = useState('');
@@ -94,8 +96,15 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
if (!dataUrl) return null;
const metadata = {
format: encryptionMode.toUpperCase(),
created: new Date().toLocaleDateString(),
recovery_url: 'github.com/kccleoc/seedpgp-web',
fingerprint: fingerprint || 'N/A',
};
return (
<div className="border-4 border-[#00f0ff] rounded-xl shadow-[0_0_40px_rgba(0,240,255,0.6)] p-4 bg-[#0a0a0f] space-y-4">
<div className="border-4 border-[#00f0ff] rounded-xl shadow-[0_0_40px_rgba(0,240,255,0.6)] p-4 bg-[#0a0a0f] space-y-4 qr-container">
<div className="bg-[#16213e] p-6 rounded-lg inline-block shadow-[0_0_20px_rgba(0,240,255,0.3)] border-2 border-[#00f0ff]/30">
<img src={dataUrl} alt="QR Code" className="w-full h-auto" />
</div>
@@ -106,6 +115,27 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
</div>
)}
{/* NEW: Metadata below QR */}
<div className="bg-[#0a0a0f] border border-[#00f0ff]/30 rounded-lg p-3 text-xs font-mono qr-metadata">
<div className="grid grid-cols-2 gap-2 text-[#6ef3f7]">
<div>Format:</div>
<div className="text-[#00f0ff] font-bold">{metadata.format}</div>
<div>Created:</div>
<div className="text-[#00f0ff]">{metadata.created}</div>
{fingerprint && (
<>
<div>PGP Key:</div>
<div className="text-[#00f0ff] break-all">{metadata.fingerprint.slice(0, 16)}...</div>
</>
)}
<div>Recovery Guide:</div>
<div className="text-[#00f0ff]">{metadata.recovery_url}</div>
</div>
</div>
<button
onClick={handleDownload}
className="flex items-center gap-2 px-4 py-2 bg-[#00f0ff] hover:bg-[#00f0ff]/80 text-[#0a0a0f] rounded-lg transition-all hover:shadow-[0_0_15px_rgba(0,240,255,0.5)]"
@@ -114,6 +144,10 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
Download QR Code
</button>
<p className="text-xs text-[#6ef3f7] text-center">
💡 Screenshot this entire area (QR + metadata) for easy identification
</p>
<p className="text-xs text-[#6ef3f7]">
Downloads as: SeedPGP_{new Date().toISOString().split('T')[0]}_HHMMSS.png
</p>

View File

@@ -145,7 +145,7 @@ const RandomOrgEntropy: React.FC<RandomOrgEntropyProps> = ({
<div className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
{!stats && !processing && (
<>
<div className="p-4 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-3">
<div className="p-3 md:p-4 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-3">
<div className="flex items-center gap-2">
<Globe size={20} className="text-[#00f0ff]" />
<h3 className="text-sm font-bold text-[#00f0ff] uppercase">🌍 Random.org Entropy</h3>
@@ -226,7 +226,7 @@ const RandomOrgEntropy: React.FC<RandomOrgEntropyProps> = ({
readOnly
value={requestJson}
onFocus={(e) => e.currentTarget.select()}
className="w-full h-36 p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-[10px] text-[#00f0ff] focus:outline-none"
className="w-full h-28 md:h-36 p-2 md:p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-[10px] text-[#00f0ff] focus:outline-none"
/>
<p className="text-[10px] text-[#6ef3f7]">
Endpoint: <span className="font-mono text-[#00f0ff]">https://api.random.org/json-rpc/1/invoke</span>
@@ -239,7 +239,7 @@ const RandomOrgEntropy: React.FC<RandomOrgEntropyProps> = ({
value={paste}
onChange={(e) => setPaste(e.target.value)}
placeholder="Paste JSON-RPC response, or paste a [1,6,2,...] array"
className="w-full h-36 p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-[10px] text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] transition-all"
className="w-full h-28 md:h-36 p-2 md:p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-[10px] text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] transition-all"
/>
</div>

View File

@@ -74,7 +74,9 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
const [finalMnemonic, setFinalMnemonic] = useState<string | null>(null);
const [mixing, setMixing] = useState(false);
const [showFinalQR, setShowFinalQR] = useState(false);
const [copiedFinal, setCopiedFinal] = useState(false);
const [targetWordCount, setTargetWordCount] = useState<12 | 24>(24);
useEffect(() => {
const isDirty = entries.some(e => e.rawInput.length > 0) || diceRolls.length > 0;
onDirtyStateChange(isDirty);
@@ -262,7 +264,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
if (!blendedResult) return;
setMixing(true);
try {
const outputBits = blendedResult.blendedEntropy.length >= 32 ? 256 : 128;
const outputBits = targetWordCount === 12 ? 128 : 256;
const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits);
setFinalMnemonic(result.finalMnemonic);
} catch (e) { setFinalMnemonic(null); } finally { setMixing(false); }
@@ -278,6 +280,25 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
// This preserves the blended seed in case user wants to come back and export QR
};
const copyFinalMnemonic = async () => {
if (!finalMnemonic) return;
try {
await navigator.clipboard.writeText(finalMnemonic);
setCopiedFinal(true);
window.setTimeout(() => setCopiedFinal(false), 1200);
} catch {
// fallback: select manually
const el = document.getElementById("final-mnemonic");
if (el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
};
const getBorderColor = (isValid: boolean | null) => {
if (isValid === true) return 'border-[#39ff14] focus:ring-[#39ff14]';
if (isValid === false) return 'border-[#ff006e] focus:ring-[#ff006e]';
@@ -286,18 +307,18 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
return (
<>
<div className="space-y-6 pb-20">
<div className="mb-6">
<h2 className="text-lg font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
<div className="space-y-4 md:space-y-6 pb-10 md:pb-20">
<div className="mb-3 md:mb-6">
<h2 className="text-base md:text-lg font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
Seed Blender
</h2>
</div>
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 1: Input Mnemonics</h3>
<div className="space-y-4">
<div className="p-3 md:p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
<h3 className="font-semibold text-base md:text-lg mb-2 md:mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 1: Input Mnemonics</h3>
<div className="space-y-3 md:space-y-4">
{entries.map((entry, index) => (
<div key={entry.id} className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/20">
<div key={entry.id} className="p-2 md:p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/20">
{entry.passwordRequired ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><label className="text-sm font-semibold text-[#00f0ff]">Decrypt {entry.inputType.toUpperCase()} Mnemonic</label><button onClick={() => updateEntry(index, createNewEntry())} className="text-xs text-[#6ef3f7] hover:text-[#00f0ff]">&times; Cancel</button></div>
@@ -314,9 +335,8 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
onFocus={(e) => e.target.classList.remove('blur-sensitive')}
onBlur={(e) => entry.rawInput && e.target.classList.add('blur-sensitive')}
placeholder={`Mnemonic #${index + 1} (12 or 24 words)`}
className={`w-full h-24 p-3 bg-[#0a0a0f] border-2 rounded-lg font-mono text-xs text-[#00f0ff] placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all ${getBorderColor(entry.isValid)} ${
entry.rawInput ? 'blur-sensitive' : ''
}`}
className={`w-full h-20 md:h-24 p-2 md:p-3 bg-[#0a0a0f] border-2 rounded-lg font-mono text-xs text-[#00f0ff] placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all ${getBorderColor(entry.isValid)} ${entry.rawInput ? 'blur-sensitive' : ''
}`}
/>
{/* Row 2: QR button (left) and X button (right) */}
<div className="flex items-center justify-between">
@@ -342,47 +362,114 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
)}
</div>
))}
<button onClick={handleAddEntry} className="w-full py-2.5 bg-[#1a1a2e] hover:bg-[#16213e] text-[#00f0ff] rounded-lg font-semibold flex items-center justify-center gap-2 border-2 border-[#00f0ff]/50"><Plus size={16} /> Add Another Mnemonic</button>
<button onClick={handleAddEntry} className="w-full py-2 bg-[#1a1a2e] hover:bg-[#16213e] text-[#00f0ff] rounded-lg font-semibold flex items-center justify-center gap-2 border-2 border-[#00f0ff]/50"><Plus size={16} /> Add Another Mnemonic</button>
</div>
</div>
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 min-h-[10rem]">
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 2: Blended Preview</h3>
{blending ? <p className="text-sm text-[#6ef3f7]">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><div><span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.</div></div>)}<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (12-word)</label><p data-sensitive="Blended Mnemonic (12-word)" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (24-word)</label><p data-sensitive="Blended Mnemonic (24-word)" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-[#6ef3f7]">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
<div className="p-3 md:p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 min-h-[10rem]">
<h3 className="font-semibold text-base md:text-lg mb-2 md:mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 2: Blended Preview</h3>
{blending ? <p className="text-sm text-[#6ef3f7]">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-3 md:space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><div><span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.</div></div>)}<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (12-word)</label><p data-sensitive="Blended Mnemonic (12-word)" className="p-2 md:p-3 bg-[#1a1a2e] rounded-md font-mono text-xs md:text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (24-word)</label><p data-sensitive="Blended Mnemonic (24-word)" className="p-2 md:p-3 bg-[#1a1a2e] rounded-md font-mono text-xs md:text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-[#6ef3f7]">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
</div>
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 3: Input Dice Rolls</h3>
<div className="space-y-4">
<textarea value={diceRolls} onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))} placeholder="99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-sm placeholder:text-[10px] placeholder:text-[#6ef3f7]" />
<div className="p-3 md:p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
<h3 className="font-semibold text-base md:text-lg mb-2 md:mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 3: Input Dice Rolls</h3>
<div className="space-y-3 md:space-y-4">
<textarea
value={diceRolls}
onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))}
placeholder="99+ dice rolls (e.g., 16345...)"
className="w-full h-24 md:h-32 p-2 md:p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-xs text-[#00f0ff] placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all"
/>
{dicePatternWarning && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><p><span className="font-bold">Warning:</span> {dicePatternWarning}</p></div>)}
{diceStats && diceStats.length > 0 && (<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center"><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Rolls</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.length}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Entropy (bits)</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.estimatedEntropyBits.toFixed(1)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Mean</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.mean.toFixed(2)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Chi-Square</p><p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-[#ff006e]' : 'text-[#00f0ff]'}`}>{diceStats.chiSquare.toFixed(2)}</p></div></div>)}
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Dice-Only Preview Mnemonic</label><p data-sensitive="Dice-Only Preview Mnemonic" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{diceOnlyMnemonic}</p></div>)}
{diceStats && diceStats.length > 0 && (<div className="grid grid-cols-2 md:grid-cols-4 gap-2 md:gap-4 text-center"><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Rolls</p><p className="text-base md:text-lg font-bold text-[#00f0ff]">{diceStats.length}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Entropy (bits)</p><p className="text-base md:text-lg font-bold text-[#00f0ff]">{diceStats.estimatedEntropyBits.toFixed(1)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Mean</p><p className="text-base md:text-lg font-bold text-[#00f0ff]">{diceStats.mean.toFixed(2)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Chi-Square</p><p className={`text-base md:text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-[#ff006e]' : 'text-[#00f0ff]'}`}>{diceStats.chiSquare.toFixed(2)}</p></div></div>)}
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Dice-Only Preview Mnemonic</label><p data-sensitive="Dice-Only Preview Mnemonic" className="p-2 md:p-3 bg-[#1a1a2e] rounded-md font-mono text-xs md:text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{diceOnlyMnemonic}</p></div>)}
</div>
</div>
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/50 shadow-[0_0_20px_rgba(0,240,255,0.3)]">
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 4: Generate Final Mnemonic</h3>
<div className="p-3 md:p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/50 shadow-[0_0_20px_rgba(0,240,255,0.3)]">
<h3 className="font-semibold text-base md:text-lg mb-2 md:mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 4: Generate Final Mnemonic</h3>
{finalMnemonic ? (
<div className="p-4 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-2xl shadow-[0_0_20px_rgba(57,255,20,0.3)]">
<div className="flex items-center justify-between mb-4"><span className="font-bold text-[#39ff14] flex items-center gap-2 text-lg" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}><CheckCircle2 size={22} /> Final Mnemonic Generated</span><button onClick={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-[#16213e] rounded-xl transition-all text-[#39ff14] hover:shadow-[0_0_15px_rgba(57,255,20,0.5)] flex items-center gap-2"><EyeOff size={22} /> Hide</button></div>
<div className="p-6 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_20px_rgba(57,255,20,0.3)]"><p data-sensitive="Final Blended Mnemonic" className="font-mono text-center text-lg break-words text-[#39ff14]">{finalMnemonic}</p></div>
<div className="p-4 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-2xl shadow-[0_0_20px_rgba(57,255,20,0.3)] animate-in fade-in">
<div className="flex items-center justify-between mb-4">
<span className="font-bold text-[#39ff14] flex items-center gap-2 text-lg" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}><CheckCircle2 size={22} /> Final Mnemonic Generated</span>
<button onClick={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-[#16213e] rounded-xl transition-all text-[#39ff14] hover:shadow-[0_0_15px_rgba(57,255,20,0.5)] flex items-center gap-2"><EyeOff size={22} /> Hide</button>
</div>
<div className="p-6 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_20px_rgba(57,255,20,0.3)]">
<div className="flex items-center justify-end mb-3">
<button
type="button"
onClick={copyFinalMnemonic}
className="px-3 py-1.5 bg-[#16213e] border-2 border-[#39ff14]/50 text-[#39ff14] rounded-lg text-xs font-semibold hover:shadow-[0_0_15px_rgba(57,255,20,0.35)] transition-all"
title="Copy final mnemonic"
>
{copiedFinal ? "Copied" : "Copy"}
</button>
</div>
<p
id="final-mnemonic"
data-sensitive="Final Blended Mnemonic"
className="font-mono text-center text-sm md:text-base break-words text-[#39ff14] leading-relaxed select-text cursor-text"
onClick={(e) => {
const range = document.createRange();
range.selectNodeContents(e.currentTarget);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}}
>{finalMnemonic}</p>
<p className="text-[9px] text-[#6ef3f7] mt-2 text-center">Click the words to select all, or use Copy.</p>
</div>
<div className="mt-4 p-3 bg-[#ff006e]/10 text-[#ff006e] rounded-lg text-xs flex gap-2 border-2 border-[#ff006e]/30"><AlertTriangle size={16} className="shrink-0 mt-0.5" /><span><strong>Security Warning:</strong> Write this down immediately. Do not save it digitally.</span></div>
<div className="grid grid-cols-2 gap-3 mt-4">
<button onClick={() => setShowFinalQR(true)} className="w-full py-2.5 bg-[#1a1a2e] text-[#00f0ff] rounded-lg font-semibold flex items-center justify-center gap-2 border-2 border-[#00f0ff]/50 hover:bg-[#16213e] hover:shadow-[0_0_15px_rgba(0,240,255,0.3)]"><QrCode size={16} /> Export as QR</button>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
<button
onClick={() => setShowFinalQR(true)}
className="w-full py-2.5 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] text-xs md:text-sm rounded-lg font-bold uppercase tracking-wide flex items-center justify-center gap-2 hover:bg-[#00f0ff]/20 active:scale-95 transition-all"
style={{ textShadow: '0 0 8px rgba(0,240,255,0.7)' }}
>
<QrCode size={16} /> Export as QR
</button> <button
onClick={handleTransfer}
className="w-full py-2.5 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white rounded-lg font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-[#ff006e]"
style={{ textShadow: '0 0 10px rgba(255,255,255,0.8)' }}
className="w-full py-2.5 bg-[#1a1a2e] border-2 border-[#ff006e] text-[#00f0ff] text-xs md:text-sm rounded-lg font-bold uppercase tracking-wide flex items-center justify-center gap-2 hover:shadow-[0_0_25px_rgba(255,0,110,0.7)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-[0_0_15px_rgba(255,0,110,0.5)]"
style={{ textShadow: '0 0 8px rgba(0,240,255,0.7)' }}
disabled={!finalMnemonic}
>
<ArrowRight size={20} />
Send to Backup Tab
Send to Backup
</button>
</div>
</div>
) : (
<><p className="text-sm text-[#6ef3f7] mb-4">Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final mnemonic.</p><button onClick={handleFinalMix} disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing} className="w-full py-3 bg-gradient-to-r from-[#00f0ff] to-[#0066ff] text-[#16213e] rounded-xl font-bold flex items-center justify-center gap-2 disabled:opacity-50 hover:shadow-[0_0_20px_rgba(0,240,255,0.5)]">{mixing ? <RefreshCw className="animate-spin" size={20} /> : <Sparkles size={20} />}{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}</button></>
<>
<p className="text-xs md:text-sm text-[#6ef3f7] mb-2 md:mb-4">Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final mnemonic.</p>
<div className="space-y-3 pt-4 mb-4 border-t border-[#00f0ff]/30">
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest block text-center" style={{ textShadow: '0 0 10px rgba(0, 240, 255, 0.7)' }}>
Target Seed Length
</label>
<div className="grid grid-cols-2 gap-3 max-w-sm mx-auto">
<button
onClick={() => setTargetWordCount(12)}
className={`py-2.5 text-sm rounded-lg font-medium transition-all ${targetWordCount === 12
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
: 'bg-[#16213e] text-[#9d84b7] border-2 border-[#00f0ff]/30 hover:text-[#6ef3f7] hover:border-[#00f0ff]/50'
}`}
style={targetWordCount === 12 ? { textShadow: '0 0 10px rgba(0, 240, 255, 0.8)' } : undefined}
>
12 Words
</button>
<button
onClick={() => setTargetWordCount(24)}
className={`py-2.5 text-sm rounded-lg font-medium transition-all ${targetWordCount === 24
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
: 'bg-[#16213e] text-[#9d84b7] border-2 border-[#00f0ff]/30 hover:text-[#6ef3f7] hover:border-[#00f0ff]/50'
}`}
style={targetWordCount === 24 ? { textShadow: '0 0 10px rgba(0, 240, 255, 0.8)' } : undefined}
>
24 Words
</button>
</div>
</div>
<button onClick={handleFinalMix} disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing} className="w-full py-3 bg-gradient-to-r from-[#00f0ff] to-[#0066ff] text-[#16213e] rounded-xl font-bold flex items-center justify-center gap-2 disabled:opacity-50 hover:shadow-[0_0_20px_rgba(0,240,255,0.5)]">{mixing ? <RefreshCw className="animate-spin" size={20} /> : <Sparkles size={20} />}{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}</button>
</>
)}
</div>
</div>

View File

@@ -0,0 +1,403 @@
import React, { useState } from 'react';
import { AlertCircle, CheckCircle2, PlayCircle, RefreshCw, Package, Lock, Unlock } from 'lucide-react';
import { generateRecoveryKit } from '../lib/recoveryKit';
import { encryptToSeed, decryptFromSeed } from '../lib/seedpgp';
import { entropyToMnemonic } from '../lib/seedblend';
type TestStep = 'intro' | 'generate' | 'encrypt' | 'download' | 'clear' | 'recover' | 'verify' | 'complete';
export const TestRecovery: React.FC = () => {
const [currentStep, setCurrentStep] = useState<TestStep>('intro');
const [dummySeed, setDummySeed] = useState('');
const [testPassword, setTestPassword] = useState('TestPassword123!');
const [recoveredSeed, setRecoveredSeed] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [encryptedBackup, setEncryptedBackup] = useState<string>('');
const generateDummySeed = async () => {
try {
setLoading(true);
setError('');
// Generate a random 12-word BIP39 mnemonic for testing
const entropy = crypto.getRandomValues(new Uint8Array(16));
const mnemonic = await entropyToMnemonic(entropy);
setDummySeed(mnemonic);
setCurrentStep('encrypt');
} catch (err: any) {
setError(`Failed to generate dummy seed: ${err.message}`);
} finally {
setLoading(false);
}
};
const encryptDummySeed = async () => {
try {
setLoading(true);
setError('');
// Encrypt using the same logic as real backups
const result = await encryptToSeed({
plaintext: dummySeed,
messagePassword: testPassword,
mode: 'pgp',
});
// Store encrypted backup
setEncryptedBackup(result.framed as string);
setCurrentStep('download');
} catch (err: any) {
setError(`Failed to encrypt dummy seed: ${err.message}`);
} finally {
setLoading(false);
}
};
const downloadRecoveryKit = async () => {
try {
setLoading(true);
setError('');
// Generate and download recovery kit with test backup
const kitBlob = await generateRecoveryKit({
encryptedData: encryptedBackup,
encryptionMode: 'pgp',
encryptionMethod: 'password',
qrImageDataUrl: undefined, // No QR image for test
});
// Trigger download
const url = URL.createObjectURL(kitBlob);
const a = document.createElement('a');
a.href = url;
a.download = `seedpgp-test-recovery-kit-${new Date().toISOString().split('T')[0]}.zip`;
a.click();
URL.revokeObjectURL(url);
alert('✅ Recovery kit downloaded! Now let\'s test if you can recover the seed.');
setCurrentStep('clear');
} catch (err: any) {
setError(`Failed to generate recovery kit: ${err.message}`);
} finally {
setLoading(false);
}
};
const clearDummySeed = () => {
// Clear the dummy seed from state (simulating app unavailability)
setDummySeed('');
setRecoveredSeed('');
alert('✅ Dummy seed cleared. Now follow the recovery instructions to get it back!');
setCurrentStep('recover');
};
const recoverFromBackup = async () => {
try {
setLoading(true);
setError('');
// Decrypt using recovery instructions
const result = await decryptFromSeed({
frameText: encryptedBackup,
messagePassword: testPassword,
mode: 'pgp',
});
setRecoveredSeed(result.w);
setCurrentStep('verify');
} catch (err: any) {
setError(`❌ Recovery failed: ${err.message}`);
} finally {
setLoading(false);
}
};
const verifyRecovery = () => {
if (recoveredSeed === dummySeed) {
setCurrentStep('complete');
alert('🎉 SUCCESS! You successfully recovered the seed phrase!');
} else {
alert('❌ FAILED: Recovered seed does not match original. Try again.');
}
};
const resetTest = () => {
setCurrentStep('intro');
setDummySeed('');
setTestPassword('TestPassword123!');
setRecoveredSeed('');
setEncryptedBackup('');
setError('');
};
return (
<div className="max-w-4xl mx-auto p-6">
<div className="bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 p-6">
<h2 className="text-2xl font-bold text-[#00f0ff] mb-4">
🧪 Test Your Recovery Ability
</h2>
{error && (
<div className="p-4 bg-[#1a1a2e] border-2 border-[#ff006e] rounded-lg text-[#ff006e] text-sm shadow-[0_0_20px_rgba(255,0,110,0.3)] flex gap-3 items-start mb-4">
<AlertCircle className="shrink-0 mt-0.5" size={20} />
<div>
<p className="font-bold mb-1">Error</p>
<p className="whitespace-pre-wrap">{error}</p>
</div>
</div>
)}
{currentStep === 'intro' && (
<div className="space-y-4">
<p className="text-[#6ef3f7]">
This drill will help you practice recovering a seed phrase from an encrypted backup.
You'll learn the recovery process without risking your real funds.
</p>
<div className="bg-[#0a0a0f] border border-[#ff006e] rounded-lg p-4">
<h3 className="text-[#ff006e] font-bold mb-2">What You'll Do:</h3>
<ol className="text-sm text-[#6ef3f7] space-y-1 list-decimal list-inside">
<li>Generate a dummy test seed</li>
<li>Encrypt it with a test password</li>
<li>Download the recovery kit</li>
<li>Clear the seed from your browser</li>
<li>Follow recovery instructions to decrypt</li>
<li>Verify you got the correct seed back</li>
</ol>
</div>
<button
onClick={generateDummySeed}
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white rounded-xl font-bold uppercase flex items-center justify-center gap-2"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<PlayCircle size={20} />
)}
{loading ? 'Generating...' : 'Start Test Recovery Drill'}
</button>
</div>
)}
{currentStep === 'generate' && (
<div className="space-y-4">
<CheckCircle2 className="text-[#39ff14]" size={32} />
<h3 className="text-[#39ff14] font-bold">Step 1: Dummy Seed Generated</h3>
<div className="bg-[#0a0a0f] p-4 rounded-lg">
<p className="text-xs text-[#6ef3f7] mb-2">Test Seed (DO NOT USE FOR REAL FUNDS):</p>
<p className="font-mono text-sm text-[#00f0ff]">{dummySeed}</p>
</div>
<button
onClick={encryptDummySeed}
disabled={loading}
className="w-full py-3 bg-[#00f0ff] text-[#0a0a0f] rounded-xl font-bold flex items-center justify-center gap-2"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Lock size={20} />
)}
{loading ? 'Encrypting...' : 'Next: Encrypt This Seed'}
</button>
</div>
)}
{currentStep === 'encrypt' && (
<div className="space-y-4">
<CheckCircle2 className="text-[#39ff14]" size={32} />
<h3 className="text-[#39ff14] font-bold">Step 2: Seed Encrypted</h3>
<div className="bg-[#0a0a0f] p-4 rounded-lg">
<p className="text-xs text-[#6ef3f7] mb-2">Test Password:</p>
<p className="font-mono text-sm text-[#00f0ff]">{testPassword}</p>
<p className="text-xs text-[#6ef3f7] mt-2">Seed has been encrypted with PGP using password-based encryption.</p>
</div>
<button
onClick={downloadRecoveryKit}
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-[#00f0ff] to-[#00c8ff] text-[#0a0a0f] rounded-xl font-bold flex items-center justify-center gap-2"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Package size={20} />
)}
{loading ? 'Generating...' : 'Next: Download Recovery Kit'}
</button>
</div>
)}
{currentStep === 'download' && (
<div className="space-y-4">
<CheckCircle2 className="text-[#39ff14]" size={32} />
<h3 className="text-[#39ff14] font-bold">Step 3: Recovery Kit Downloaded</h3>
<div className="bg-[#0a0a0f] p-4 rounded-lg">
<p className="text-sm text-[#6ef3f7]">
The recovery kit ZIP file has been downloaded to your computer. It contains:
</p>
<ul className="text-xs text-[#6ef3f7] list-disc list-inside mt-2 space-y-1">
<li>Encrypted backup file</li>
<li>Recovery scripts (Python/Bash)</li>
<li>Personalized instructions</li>
<li>BIP39 wordlist</li>
<li>Metadata file</li>
</ul>
</div>
<button
onClick={clearDummySeed}
className="w-full py-3 bg-[#ff006e] text-white rounded-xl font-bold"
>
Next: Clear Seed & Test Recovery
</button>
</div>
)}
{currentStep === 'clear' && (
<div className="space-y-4">
<CheckCircle2 className="text-[#39ff14]" size={32} />
<h3 className="text-[#39ff14] font-bold">Step 4: Seed Cleared</h3>
<div className="bg-[#0a0a0f] p-4 rounded-lg">
<p className="text-sm text-[#6ef3f7]">
The dummy seed has been cleared from browser memory. This simulates what would happen if:
</p>
<ul className="text-xs text-[#6ef3f7] list-disc list-inside mt-2 space-y-1">
<li>The SeedPGP website goes down</li>
<li>You lose access to this browser</li>
<li>You need to recover from the backup alone</li>
</ul>
</div>
<button
onClick={recoverFromBackup}
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white rounded-xl font-bold flex items-center justify-center gap-2"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Unlock size={20} />
)}
{loading ? 'Decrypting...' : 'Next: Recover Seed from Backup'}
</button>
</div>
)}
{currentStep === 'recover' && (
<div className="space-y-4">
<CheckCircle2 className="text-[#39ff14]" size={32} />
<h3 className="text-[#39ff14] font-bold">Step 5: Seed Recovered</h3>
<div className="bg-[#0a0a0f] p-4 rounded-lg">
<p className="text-xs text-[#6ef3f7] mb-2">Recovered Seed:</p>
<p className="font-mono text-sm text-[#00f0ff]">{recoveredSeed}</p>
<p className="text-xs text-[#6ef3f7] mt-2">
The seed has been successfully decrypted from the backup using the test password.
</p>
</div>
<button
onClick={verifyRecovery}
className="w-full py-3 bg-[#39ff14] text-[#0a0a0f] rounded-xl font-bold"
>
Next: Verify Recovery
</button>
</div>
)}
{currentStep === 'verify' && (
<div className="space-y-4">
<CheckCircle2 className="text-[#39ff14]" size={32} />
<h3 className="text-[#39ff14] font-bold">Step 6: Verification</h3>
<div className="bg-[#0a0a0f] p-4 rounded-lg">
<p className="text-sm text-[#6ef3f7]">
Comparing original seed with recovered seed...
</p>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<p className="text-xs text-[#6ef3f7] mb-1">Original:</p>
<p className="font-mono text-xs text-[#00f0ff] truncate">{dummySeed}</p>
</div>
<div>
<p className="text-xs text-[#6ef3f7] mb-1">Recovered:</p>
<p className="font-mono text-xs text-[#00f0ff] truncate">{recoveredSeed}</p>
</div>
</div>
</div>
<button
onClick={() => {
if (recoveredSeed === dummySeed) {
setCurrentStep('complete');
alert('🎉 SUCCESS! You successfully recovered the seed phrase!');
} else {
alert('❌ FAILED: Recovered seed does not match original. Try again.');
}
}}
className="w-full py-3 bg-gradient-to-r from-[#39ff14] to-[#00ff88] text-[#0a0a0f] rounded-xl font-bold"
>
Verify Match
</button>
</div>
)}
{currentStep === 'complete' && (
<div className="space-y-4 text-center">
<CheckCircle2 className="text-[#39ff14] mx-auto" size={64} />
<h3 className="text-2xl font-bold text-[#39ff14]">🎉 Test Passed!</h3>
<p className="text-[#6ef3f7]">
You've successfully proven you can recover a seed phrase from an encrypted backup.
You're ready to trust this system with real funds.
</p>
<div className="bg-[#0a0a0f] border border-[#39ff14] rounded-lg p-4 mt-4">
<h4 className="text-[#39ff14] font-bold mb-2">Key Takeaways:</h4>
<ul className="text-sm text-[#6ef3f7] space-y-1 text-left">
<li> You can decrypt backups without the SeedPGP website</li>
<li> The recovery kit contains everything needed</li>
<li> You understand the recovery process</li>
<li> Your real backups are recoverable</li>
</ul>
</div>
<button
onClick={resetTest}
className="py-3 px-6 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] rounded-xl font-bold flex items-center justify-center gap-2 mx-auto"
>
<RefreshCw size={16} />
Run Test Again
</button>
</div>
)}
{/* Progress indicator */}
<div className="mt-6 pt-4 border-t border-[#00f0ff]/20">
<div className="flex justify-between text-xs text-[#6ef3f7] mb-2">
<span>Progress:</span>
<span>
{currentStep === 'intro' && '0/7'}
{currentStep === 'generate' && '1/7'}
{currentStep === 'encrypt' && '2/7'}
{currentStep === 'download' && '3/7'}
{currentStep === 'clear' && '4/7'}
{currentStep === 'recover' && '5/7'}
{currentStep === 'verify' && '6/7'}
{currentStep === 'complete' && '7/7'}
</span>
</div>
<div className="w-full bg-[#0a0a0f] rounded-full h-2">
<div
className="bg-gradient-to-r from-[#00f0ff] to-[#00c8ff] h-2 rounded-full transition-all duration-300"
style={{
width: currentStep === 'intro' ? '0%' :
currentStep === 'generate' ? '14%' :
currentStep === 'encrypt' ? '28%' :
currentStep === 'download' ? '42%' :
currentStep === 'clear' ? '57%' :
currentStep === 'recover' ? '71%' :
currentStep === 'verify' ? '85%' :
'100%'
}}
/>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach } from 'bun:test';
import { detectEncryptionMode } from './../lib/seedpgp';
import {
encryptJsonToBlob,
decryptBlobToJson,
destroySessionKey,
getSessionKey,
} from './../lib/sessionCrypto';
describe('Security Fixes Verification', () => {
describe('F-02: Regex Fix for SeedQR detection', () => {
it('should correctly detect a standard numeric SeedQR', () => {
// A 48-digit string, representing 12 words
const numericSeedQR = '000011112222333344445555666677778888999901234567';
const mode = detectEncryptionMode(numericSeedQR);
expect(mode).toBe('seedqr');
});
it('should not detect a short numeric string as SeedQR', () => {
const shortNumeric = '1234567890'; // 10 digits - too short for SeedQR
const mode = detectEncryptionMode(shortNumeric);
// ✅ FIXED: Short numeric strings should NOT be detected as SeedQR
// (They may be detected as 'krux' if they match Base43 charset, which is fine)
expect(mode).not.toBe('seedqr');
});
it('should not detect a mixed-character string as numeric SeedQR', () => {
const mixedString = '00001111222233334444555566667777888899990123456a';
const mode = detectEncryptionMode(mixedString);
expect(mode).not.toBe('seedqr');
});
});
describe('F-01: Session Key Rotation Data Loss Fix', () => {
beforeEach(() => {
destroySessionKey();
});
it('should include a keyId in the encrypted blob', async () => {
const data = { secret: 'hello world' };
const blob = await encryptJsonToBlob(data);
expect(blob.keyId).toBeDefined();
expect(typeof blob.keyId).toBe('string');
expect(blob.keyId.length).toBeGreaterThan(0); // Additional check
});
it('should successfully decrypt a blob with the correct keyId', async () => {
const data = { secret: 'this is a test' };
const blob = await encryptJsonToBlob(data);
const decrypted = await decryptBlobToJson(blob);
expect(decrypted).toEqual(data);
});
it('should throw an error if the key is rotated before decryption', async () => {
const data = { secret: 'will be lost' };
const blob = await encryptJsonToBlob(data);
// Force key rotation by destroying the current one and getting a new one
destroySessionKey();
await getSessionKey(); // Generates a new key with a new keyId
// Decryption should now fail because the keyId in the blob does not match
await expect(decryptBlobToJson(blob)).rejects.toThrow(
'Session expired. The encryption key has rotated. Please re-enter your seed phrase.'
);
});
});
});

View File

@@ -2,37 +2,37 @@
import wordlistTxt from '../bip39_wordlist.txt?raw';
// --- BIP39 Wordlist Loading ---
export const BIP39_WORDLIST: readonly string[] = wordlistTxt.trim().split('\n');
export const BIP39_WORDLIST: readonly string[] = wordlistTxt.trim().split(/\r?\n/);
export const WORD_INDEX = new Map<string, number>(
BIP39_WORDLIST.map((word, index) => [word, index])
BIP39_WORDLIST.map((word, index) => [word, index])
);
if (BIP39_WORDLIST.length !== 2048) {
throw new Error(`Invalid wordlist loaded: expected 2048 words, got ${BIP39_WORDLIST.length}`);
throw new Error(`Invalid wordlist loaded: expected 2048 words, got ${BIP39_WORDLIST.length}`);
}
// --- Web Crypto API Helpers ---
async function getCrypto(): Promise<SubtleCrypto> {
if (globalThis.crypto?.subtle) {
return globalThis.crypto.subtle;
}
try {
const { webcrypto } = await import('crypto');
if (webcrypto?.subtle) {
return webcrypto.subtle as SubtleCrypto;
if (globalThis.crypto?.subtle) {
return globalThis.crypto.subtle;
}
} catch (e) {
// Ignore import errors
}
throw new Error("SubtleCrypto not found in this environment");
try {
const { webcrypto } = await import('crypto');
if (webcrypto?.subtle) {
return webcrypto.subtle as SubtleCrypto;
}
} catch (e) {
// Ignore import errors
}
throw new Error("SubtleCrypto not found in this environment");
}
async function sha256(data: Uint8Array): Promise<Uint8Array> {
const subtle = await getCrypto();
// Create a new Uint8Array to ensure the underlying buffer is not shared.
const dataCopy = new Uint8Array(data);
const hashBuffer = await subtle.digest('SHA-256', dataCopy);
return new Uint8Array(hashBuffer);
const subtle = await getCrypto();
// Create a new Uint8Array to ensure the underlying buffer is not shared.
const dataCopy = new Uint8Array(data);
const hashBuffer = await subtle.digest('SHA-256', dataCopy);
return new Uint8Array(hashBuffer);
}
// --- Public API ---

2368
src/lib/recoveryKit.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,6 @@ import type {
// 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 {
if (!s) return undefined;
@@ -416,7 +414,7 @@ export function detectEncryptionMode(text: string): EncryptionMode {
}
// 3. Standard SeedQR (all digits)
if (/^\\d+$/.test(trimmed) && trimmed.length >= 48) { // 12 words * 4 digits
if (/^\d+$/.test(trimmed) && trimmed.length >= 48) { // 12 words * 4 digits
return 'seedqr';
}

View File

@@ -29,6 +29,7 @@ function bytesToBase64(bytes: Uint8Array): string {
* @private
*/
let sessionKey: CryptoKey | null = null;
let sessionKeyId: string | null = null;
let keyCreatedAt = 0;
let keyOperationCount = 0;
const KEY_ALGORITHM = 'AES-GCM';
@@ -46,6 +47,7 @@ export interface EncryptedBlob {
* uses `{ name: "AES-GCM", length: 256 }`.
*/
alg: 'A256GCM';
keyId: string; // The ID of the key used for encryption
iv_b64: string; // Initialization Vector (base64)
ct_b64: string; // Ciphertext (base64)
}
@@ -56,7 +58,7 @@ export interface EncryptedBlob {
* Get or create session key with automatic rotation.
* Key rotates every 5 minutes or after 1000 operations.
*/
export async function getSessionKey(): Promise<CryptoKey> {
export async function getSessionKey(): Promise<{ key: CryptoKey; keyId: string }> {
const now = Date.now();
const shouldRotate =
!sessionKey ||
@@ -69,9 +71,11 @@ export async function getSessionKey(): Promise<CryptoKey> {
const elapsed = now - keyCreatedAt;
console.debug?.(`Rotating session key (age: ${elapsed}ms, ops: ${keyOperationCount})`);
sessionKey = null;
sessionKeyId = null;
}
const key = await window.crypto.subtle.generateKey(
// ✅ FIXED: Use global `crypto` instead of `window.crypto` for Node.js/Bun compatibility
const key = await crypto.subtle.generateKey(
{
name: KEY_ALGORITHM,
length: KEY_LENGTH,
@@ -80,11 +84,12 @@ export async function getSessionKey(): Promise<CryptoKey> {
['encrypt', 'decrypt'],
);
sessionKey = key;
sessionKeyId = crypto.randomUUID();
keyCreatedAt = now;
keyOperationCount = 0;
}
return sessionKey!;
return { key: sessionKey!, keyId: sessionKeyId! };
}
/**
@@ -93,17 +98,19 @@ export async function getSessionKey(): Promise<CryptoKey> {
* @returns A promise that resolves to an EncryptedBlob.
*/
export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
const key = await getSessionKey(); // Ensures key exists and handles rotation
const { key, keyId } = await getSessionKey(); // Ensures key exists and handles rotation
keyOperationCount++; // Track operations for rotation
if (!key) {
throw new Error('Session key not initialized. Call getSessionKey() first.');
throw new Error('Session key not initialized or has been destroyed.');
}
const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV is recommended for AES-GCM
// ✅ FIXED: Use global `crypto` instead of `window.crypto`
const iv = 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(
// ✅ FIXED: Use global `crypto` instead of `window.crypto`
const ciphertext = await crypto.subtle.encrypt(
{
name: KEY_ALGORITHM,
iv: new Uint8Array(iv),
@@ -115,6 +122,7 @@ export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
return {
v: 1,
alg: 'A256GCM',
keyId: keyId,
iv_b64: bytesToBase64(iv),
ct_b64: bytesToBase64(new Uint8Array(ciphertext)),
};
@@ -126,7 +134,7 @@ export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
* @returns A promise that resolves to the original decrypted object.
*/
export async function decryptBlobToJson<T>(blob: EncryptedBlob): Promise<T> {
const key = await getSessionKey(); // Ensures key exists and handles rotation
const { key, keyId } = await getSessionKey(); // Ensures key exists and handles rotation
keyOperationCount++; // Track operations for rotation
if (!key) {
@@ -135,11 +143,15 @@ export async function decryptBlobToJson<T>(blob: EncryptedBlob): Promise<T> {
if (blob.v !== 1 || blob.alg !== 'A256GCM') {
throw new Error('Invalid or unsupported encrypted blob format.');
}
if (blob.keyId !== keyId) {
throw new Error('Session expired. The encryption key has rotated. Please re-enter your seed phrase.');
}
const iv = base64ToBytes(blob.iv_b64);
const ciphertext = base64ToBytes(blob.ct_b64);
const decrypted = await window.crypto.subtle.decrypt(
// ✅ FIXED: Use global `crypto` instead of `window.crypto`
const decrypted = await crypto.subtle.decrypt(
{
name: KEY_ALGORITHM,
iv: new Uint8Array(iv),
@@ -158,6 +170,7 @@ export async function decryptBlobToJson<T>(blob: EncryptedBlob): Promise<T> {
*/
export function destroySessionKey(): void {
sessionKey = null;
sessionKeyId = null;
keyOperationCount = 0;
keyCreatedAt = 0;
}

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

@@ -6,6 +6,12 @@ declare module '*.css' {
export default content;
}
// Allow importing text files as raw strings
declare module '*?raw' {
const content: string;
export default content;
}
declare const __APP_VERSION__: string;
declare const __BUILD_HASH__: string;
declare const __BUILD_TIMESTAMP__: string;

0
vite-env.d.ts vendored Normal file
View File

View File

@@ -17,8 +17,9 @@ export default defineConfig({
plugins: [
wasm(),
topLevelAwait(),
basicSsl(),
react(),
// basicSsl() plugin removed - it was causing MIME type issues with raw imports
// Enable only when specifically needed for HTTPS development
{
name: 'html-transform',
transformIndexHtml(html) {
@@ -27,11 +28,17 @@ export default defineConfig({
}
],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
https: true,
headers: {
'Content-Security-Policy': '', // Empty CSP for dev
},
},
preview: {
headers: {
'Content-Security-Policy': '', // Empty for preview too
},
},
resolve: {
alias: {
buffer: 'buffer',
@@ -44,7 +51,7 @@ export default defineConfig({
}
}
},
base: '/', // Always use root, since we're Cloudflare Pages only
base: process.env.VITE_BASE_PATH || './', // Use relative paths for offline compatibility
publicDir: 'public', // ← Explicitly set (should be default)
build: {
outDir: 'dist',