mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
- 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
500 lines
15 KiB
Markdown
500 lines
15 KiB
Markdown
# 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.
|