Files
seedpgp-web/doc/SECURITY_PATCHES.md
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

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.