- 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
15 KiB
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>:
<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:
const [mnemonic, setMnemonic] = useState('');
const [backupMessagePassword, setBackupMessagePassword] = useState('');
Change To:
// 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:
export function validateBip39Mnemonic(words: string): { valid: boolean; error?: string } {
// ... word count only
return { valid: true };
}
With:
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:
// 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:
// 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:
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:
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:
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:
// 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:
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:
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:
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:
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.