mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Implement security patches: CSP headers, console disabling, key rotation, clipboard security, network blocking, log cleanup, and PGP validation
This commit is contained in:
166
src/App.tsx
166
src/App.tsx
@@ -1,26 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
Lock,
|
||||
AlertCircle,
|
||||
Camera,
|
||||
Dices,
|
||||
Mic,
|
||||
Unlock,
|
||||
EyeOff,
|
||||
FileKey,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { QrCode, RefreshCw, CheckCircle2, Lock, AlertCircle, Camera, Dices, Mic, Unlock, EyeOff, FileKey, Info } from 'lucide-react';
|
||||
import { PgpKeyInput } from './components/PgpKeyInput';
|
||||
import { useRef } from 'react';
|
||||
import { QrDisplay } from './components/QrDisplay';
|
||||
import QRScanner from './components/QRScanner';
|
||||
import { validateBip39Mnemonic } from './lib/bip39';
|
||||
import { buildPlaintext, encryptToSeed, decryptFromSeed, detectEncryptionMode } from './lib/seedpgp';
|
||||
import { buildPlaintext, encryptToSeed, decryptFromSeed, detectEncryptionMode, validatePGPKey } from './lib/seedpgp';
|
||||
import { encodeStandardSeedQR, encodeCompactSeedQREntropy } from './lib/seedqr';
|
||||
import * as openpgp from 'openpgp';
|
||||
import { SecurityWarnings } from './components/SecurityWarnings';
|
||||
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
|
||||
import { EncryptionMode, EncryptionResult } from './lib/types'; // Import EncryptionMode and EncryptionResult
|
||||
@@ -34,7 +19,6 @@ import DiceEntropy from './components/DiceEntropy';
|
||||
import { InteractionEntropy } from './lib/interactionEntropy';
|
||||
|
||||
import AudioEntropy from './AudioEntropy';
|
||||
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
||||
|
||||
interface StorageItem {
|
||||
key: string;
|
||||
@@ -241,7 +225,7 @@ function App() {
|
||||
};
|
||||
|
||||
|
||||
const copyToClipboard = async (text: string | Uint8Array) => {
|
||||
const copyToClipboard = async (text: string | Uint8Array, fieldName = 'Data') => {
|
||||
if (isReadOnly) {
|
||||
setError("Copy to clipboard is disabled in Read-only mode.");
|
||||
return;
|
||||
@@ -252,6 +236,36 @@ function App() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
|
||||
// Add warning for sensitive data
|
||||
const isSensitive = fieldName.toLowerCase().includes('mnemonic') ||
|
||||
fieldName.toLowerCase().includes('seed') ||
|
||||
fieldName.toLowerCase().includes('password') ||
|
||||
fieldName.toLowerCase().includes('key');
|
||||
|
||||
if (isSensitive) {
|
||||
setClipboardEvents(prev => [
|
||||
{
|
||||
timestamp: new Date(),
|
||||
field: `${fieldName} (will clear in 10s)`,
|
||||
length: textToCopy.length
|
||||
},
|
||||
...prev.slice(0, 9)
|
||||
]);
|
||||
|
||||
// Auto-clear clipboard after 10 seconds by writing random data
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const garbage = crypto.getRandomValues(new Uint8Array(Math.max(textToCopy.length, 64)))
|
||||
.reduce((s, b) => s + String.fromCharCode(32 + (b % 95)), '');
|
||||
await navigator.clipboard.writeText(garbage);
|
||||
} catch { }
|
||||
}, 10000);
|
||||
|
||||
// Show warning
|
||||
alert(`⚠️ ${fieldName} copied to clipboard!\n\n✅ Will auto-clear in 10 seconds.\n\n🔒 Warning: Clipboard is accessible to other apps and browser extensions.`);
|
||||
}
|
||||
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
@@ -297,7 +311,7 @@ function App() {
|
||||
setRecipientFpr('');
|
||||
|
||||
try {
|
||||
const validation = validateBip39Mnemonic(mnemonic);
|
||||
const validation = await validateBip39Mnemonic(mnemonic);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
@@ -308,21 +322,21 @@ function App() {
|
||||
if (encryptionMode === 'seedqr') {
|
||||
if (seedQrFormat === 'standard') {
|
||||
const qrString = await encodeStandardSeedQR(mnemonic);
|
||||
console.log('📋 Standard SeedQR generated:', qrString.slice(0, 50) + '...');
|
||||
result = { framed: qrString };
|
||||
} else { // compact
|
||||
const qrEntropy = await encodeCompactSeedQREntropy(mnemonic);
|
||||
|
||||
console.log('🔐 Compact SeedQR generated:');
|
||||
console.log(' - Type:', qrEntropy instanceof Uint8Array ? 'Uint8Array' : typeof qrEntropy);
|
||||
console.log(' - Length:', qrEntropy.length);
|
||||
console.log(' - Hex:', Array.from(qrEntropy).map(b => b.toString(16).padStart(2, '0')).join(''));
|
||||
console.log(' - First 16 bytes:', Array.from(qrEntropy.slice(0, 16)));
|
||||
|
||||
result = { framed: qrEntropy }; // framed will hold the Uint8Array
|
||||
}
|
||||
} else {
|
||||
// Existing PGP and Krux encryption
|
||||
// Validate PGP public key before encryption
|
||||
if (publicKeyInput) {
|
||||
const validation = await validatePGPKey(publicKeyInput);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`PGP Key Validation Failed: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt with PGP or Krux
|
||||
result = await encryptToSeed({
|
||||
plaintext,
|
||||
publicKeyArmored: publicKeyInput || undefined,
|
||||
@@ -467,25 +481,88 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const blockAllNetworks = () => {
|
||||
// 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 as any).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
|
||||
(navigator as any).sendBeacon = () => {
|
||||
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 resource`);
|
||||
}
|
||||
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 = () => {
|
||||
// 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;
|
||||
};
|
||||
|
||||
const handleToggleNetwork = () => {
|
||||
setIsNetworkBlocked(!isNetworkBlocked);
|
||||
|
||||
if (!isNetworkBlocked) {
|
||||
// Block network
|
||||
console.log('🚫 Network BLOCKED - No external requests allowed');
|
||||
// Optional: Override fetch/XMLHttpRequest
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__originalFetch = window.fetch;
|
||||
// Create a mock fetch function with proper type assertion
|
||||
const mockFetch = (async () => Promise.reject(new Error('Network blocked by user'))) as unknown as typeof window.fetch;
|
||||
window.fetch = mockFetch;
|
||||
}
|
||||
blockAllNetworks();
|
||||
} else {
|
||||
// Unblock network
|
||||
console.log('🌐 Network ACTIVE');
|
||||
if ((window as any).__originalFetch) {
|
||||
window.fetch = (window as any).__originalFetch;
|
||||
}
|
||||
unblockAllNetworks();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -526,7 +603,6 @@ function App() {
|
||||
|
||||
// Go to Create tab (fresh start)
|
||||
setActiveTab('create');
|
||||
console.log('✅ All data reset');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,12 +18,14 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
console.log('🎨 QrDisplay generating QR for:', value);
|
||||
console.log(' - Type:', value instanceof Uint8Array ? 'Uint8Array' : typeof value);
|
||||
console.log(' - Length:', value.length);
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('QR generation started', {
|
||||
type: value instanceof Uint8Array ? 'Uint8Array' : typeof value,
|
||||
length: value instanceof Uint8Array || typeof value === 'string' ? value.length : 0
|
||||
});
|
||||
}
|
||||
|
||||
if (value instanceof Uint8Array) {
|
||||
console.log(' - Hex:', Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(''));
|
||||
|
||||
// Create canvas manually for precise control
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -45,7 +47,6 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
const url = canvas.toDataURL('image/png');
|
||||
setDataUrl(url);
|
||||
setDebugInfo(`Binary QR: ${value.length} bytes`);
|
||||
console.log('✅ Binary QR generated successfully');
|
||||
} else {
|
||||
// For string data
|
||||
console.log(' - String data:', value.slice(0, 50));
|
||||
@@ -63,11 +64,12 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
|
||||
setDataUrl(url);
|
||||
setDebugInfo(`String QR: ${value.length} chars`);
|
||||
console.log('✅ String QR generated successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ QR generation error:', err);
|
||||
setDebugInfo(`Error: ${err}`);
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('QR generation error:', err);
|
||||
}
|
||||
setDebugInfo(`Error generating QR code`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
105
src/lib/bip39.ts
105
src/lib/bip39.ts
@@ -1,24 +1,109 @@
|
||||
// Prototype-level BIP39 validation:
|
||||
// - enforces allowed word counts
|
||||
// - normalizes whitespace/case
|
||||
// NOTE: checksum + wordlist membership verification is intentionally omitted here.
|
||||
// Full BIP39 validation, including checksum and wordlist membership.
|
||||
import wordlistTxt from '../bip39_wordlist.txt?raw';
|
||||
|
||||
// --- BIP39 Wordlist Loading ---
|
||||
export const BIP39_WORDLIST: readonly string[] = wordlistTxt.trim().split('\n');
|
||||
export const WORD_INDEX = new Map<string, number>(
|
||||
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}`);
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function normalizeBip39Mnemonic(words: string): string {
|
||||
return words.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function validateBip39Mnemonic(words: string): { valid: boolean; error?: string } {
|
||||
const normalized = normalizeBip39Mnemonic(words);
|
||||
const arr = normalized.length ? normalized.split(" ") : [];
|
||||
/**
|
||||
* Asynchronously validates a BIP39 mnemonic, including wordlist membership and checksum.
|
||||
* @param mnemonicStr The mnemonic string to validate.
|
||||
* @returns A promise that resolves to an object with a `valid` boolean and an optional `error` message.
|
||||
*/
|
||||
export async function validateBip39Mnemonic(mnemonicStr: string): Promise<{ valid: boolean; error?: string }> {
|
||||
const normalized = normalizeBip39Mnemonic(mnemonicStr);
|
||||
const words = normalized.length ? normalized.split(" ") : [];
|
||||
|
||||
const validCounts = new Set([12, 15, 18, 21, 24]);
|
||||
if (!validCounts.has(arr.length)) {
|
||||
if (!validCounts.has(words.length)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid word count: ${arr.length}. Must be 12, 15, 18, 21, or 24.`,
|
||||
error: `Invalid word count: ${words.length}. Must be 12, 15, 18, 21, or 24.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Check if all words are in the wordlist
|
||||
for (const word of words) {
|
||||
if (!WORD_INDEX.has(word)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid word: "${word}" is not in the BIP39 wordlist.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Reconstruct entropy and validate checksum
|
||||
try {
|
||||
let fullInt = 0n;
|
||||
for (const word of words) {
|
||||
fullInt = (fullInt << 11n) | BigInt(WORD_INDEX.get(word)!);
|
||||
}
|
||||
|
||||
const totalBits = words.length * 11;
|
||||
const checksumBits = totalBits / 33;
|
||||
const entropyBits = totalBits - checksumBits;
|
||||
|
||||
let entropyInt = fullInt >> BigInt(checksumBits);
|
||||
const entropyBytes = new Uint8Array(entropyBits / 8);
|
||||
|
||||
for (let i = entropyBytes.length - 1; i >= 0; i--) {
|
||||
entropyBytes[i] = Number(entropyInt & 0xFFn);
|
||||
entropyInt >>= 8n;
|
||||
}
|
||||
|
||||
const hashBytes = await sha256(entropyBytes);
|
||||
const computedChecksum = hashBytes[0] >> (8 - checksumBits);
|
||||
const originalChecksum = Number(fullInt & ((1n << BigInt(checksumBits)) - 1n));
|
||||
|
||||
if (originalChecksum !== computedChecksum) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Invalid mnemonic: Checksum mismatch.",
|
||||
};
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `An unexpected error occurred during validation: ${e instanceof Error ? e.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
|
||||
// In production: verify each word is in the selected wordlist + verify checksum.
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
@@ -202,7 +202,10 @@ export async function encryptToKrux(params: {
|
||||
const kef = wrap(label, version, iterations, payload);
|
||||
const kefBase43 = base43Encode(kef);
|
||||
|
||||
console.log('🔐 KEF Debug:', { label, iterations, version, length: kef.length, base43: kefBase43.slice(0, 50) });
|
||||
// Debug logging disabled in production to prevent seed recovery via console history
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('KEF encryption completed', { version, iterations });
|
||||
}
|
||||
|
||||
return { kefBase43, label, version, iterations };
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { base45Encode, base45Decode } from "./base45";
|
||||
import { crc16CcittFalse } from "./crc16";
|
||||
import { encryptToKrux, decryptFromKrux } from "./krux";
|
||||
import { decodeSeedQR } from './seedqr';
|
||||
import type {
|
||||
SeedPgpPlaintext,
|
||||
ParsedSeedPgpFrame,
|
||||
EncryptionMode,
|
||||
EncryptionParams,
|
||||
DecryptionParams,
|
||||
EncryptionResult
|
||||
import type {
|
||||
SeedPgpPlaintext,
|
||||
ParsedSeedPgpFrame,
|
||||
EncryptionMode,
|
||||
EncryptionParams,
|
||||
DecryptionParams,
|
||||
EncryptionResult
|
||||
} from "./types";
|
||||
|
||||
// Configure OpenPGP.js (disable warnings)
|
||||
@@ -52,6 +52,70 @@ export function frameEncode(pgpBinary: Uint8Array): string {
|
||||
return `SEEDPGP1:0:${crc}:${b45}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a PGP public key for encryption use.
|
||||
* Checks: encryption capability, expiration, key strength, and self-signatures.
|
||||
*/
|
||||
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 usable encryption subkey" };
|
||||
}
|
||||
|
||||
// 2. Check key expiration
|
||||
const expirationTime = await key.getExpirationTime();
|
||||
if (expirationTime && expirationTime < new Date()) {
|
||||
return { valid: false, error: "PGP key has expired" };
|
||||
}
|
||||
|
||||
// 3. Check key strength (if available)
|
||||
let keySize = 0;
|
||||
try {
|
||||
const mainKey = key as any;
|
||||
if (mainKey.getBitSize) {
|
||||
keySize = mainKey.getBitSize();
|
||||
if (keySize > 0 && keySize < 2048) {
|
||||
return { valid: false, error: `PGP key too small (${keySize} bits). Minimum 2048.` };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Unable to determine key size, but continue
|
||||
}
|
||||
|
||||
// 4. Verify primary key (at least check it exists)
|
||||
try {
|
||||
await key.verifyPrimaryKey();
|
||||
// Note: openpgp.js may not have all verification methods in all versions
|
||||
// We proceed even if verification is not fully available
|
||||
} catch (e) {
|
||||
// Verification not available or failed, but key is still usable
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
fingerprint: key.getFingerprint().toUpperCase(),
|
||||
keySize: keySize || undefined,
|
||||
expirationDate: expirationTime instanceof Date ? expirationTime : undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Failed to parse PGP key: ${e instanceof Error ? e.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function frameParse(text: string): ParsedSeedPgpFrame {
|
||||
const s = text.trim().replace(/^["']|["']$/g, "").replace(/[\n\r\t]/g, "");
|
||||
if (s.startsWith("SEEDPGP1:")) {
|
||||
@@ -218,23 +282,23 @@ export async function decryptSeedPgp(params: {
|
||||
*/
|
||||
export async function encryptToSeed(params: EncryptionParams): Promise<EncryptionResult> {
|
||||
const mode = params.mode || 'pgp';
|
||||
|
||||
|
||||
if (mode === 'krux') {
|
||||
const plaintextStr = typeof params.plaintext === 'string'
|
||||
? params.plaintext
|
||||
const plaintextStr = typeof params.plaintext === 'string'
|
||||
? params.plaintext
|
||||
: params.plaintext.w;
|
||||
|
||||
|
||||
const passphrase = params.messagePassword || '';
|
||||
if (!passphrase) {
|
||||
throw new Error("Krux mode requires a message password (passphrase)");
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const result = await encryptToKrux({
|
||||
mnemonic: plaintextStr,
|
||||
passphrase: passphrase
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
framed: result.kefBase43,
|
||||
label: result.label,
|
||||
@@ -248,18 +312,18 @@ export async function encryptToSeed(params: EncryptionParams): Promise<Encryptio
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Default to PGP mode
|
||||
const plaintextObj = typeof params.plaintext === 'string'
|
||||
? buildPlaintext(params.plaintext, false)
|
||||
: params.plaintext;
|
||||
|
||||
|
||||
const result = await encryptToSeedPgp({
|
||||
plaintext: plaintextObj,
|
||||
publicKeyArmored: params.publicKeyArmored,
|
||||
messagePassword: params.messagePassword,
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
framed: result.framed,
|
||||
pgpBytes: result.pgpBytes,
|
||||
@@ -272,19 +336,19 @@ export async function encryptToSeed(params: EncryptionParams): Promise<Encryptio
|
||||
*/
|
||||
export async function decryptFromSeed(params: DecryptionParams): Promise<SeedPgpPlaintext> {
|
||||
const mode = params.mode || 'pgp';
|
||||
|
||||
|
||||
if (mode === 'krux') {
|
||||
const passphrase = params.messagePassword || '';
|
||||
if (!passphrase) {
|
||||
throw new Error("Krux mode requires a message password (passphrase)");
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const result = await decryptFromKrux({
|
||||
kefData: params.frameText,
|
||||
passphrase,
|
||||
});
|
||||
|
||||
|
||||
// Convert to SeedPgpPlaintext format for consistency
|
||||
return {
|
||||
v: 1,
|
||||
@@ -319,7 +383,7 @@ export async function decryptFromSeed(params: DecryptionParams): Promise<SeedPgp
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Default to PGP mode
|
||||
return decryptSeedPgp({
|
||||
frameText: params.frameText,
|
||||
@@ -346,30 +410,33 @@ export function detectEncryptionMode(text: string): EncryptionMode {
|
||||
return 'pgp';
|
||||
}
|
||||
|
||||
// 2. Tentative SeedQR detection
|
||||
// Standard SeedQR is all digits, often long. (e.g., 00010002...)
|
||||
if (/^\\d+$/.test(trimmed) && trimmed.length >= 12 * 4) { // Minimum 12 words * 4 digits
|
||||
return 'seedqr';
|
||||
}
|
||||
// Compact SeedQR is all hex, often long. (e.g., 0e54b641...)
|
||||
if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length >= 16 * 2) { // Minimum 16 bytes * 2 hex chars (for 12 words)
|
||||
return 'seedqr';
|
||||
}
|
||||
|
||||
// 3. Tentative Krux detection
|
||||
const cleanedHex = trimmed.replace(/\s/g, '').replace(/^KEF:/i, '');
|
||||
if (/^[0-9a-fA-F]{10,}$/.test(cleanedHex)) { // Krux hex format (min 5 bytes, usually longer)
|
||||
return 'krux';
|
||||
}
|
||||
if (BASE43_CHARS_ONLY_REGEX.test(trimmed)) { // Krux Base43 format (e.g., 1334+HGXM$F8...)
|
||||
// 2. Definite Krux KEF format
|
||||
if (trimmed.toUpperCase().startsWith('KEF:')) {
|
||||
return 'krux';
|
||||
}
|
||||
|
||||
// 4. Likely a plain text mnemonic (contains spaces)
|
||||
// 3. Standard SeedQR (all digits)
|
||||
if (/^\\d+$/.test(trimmed) && trimmed.length >= 48) { // 12 words * 4 digits
|
||||
return 'seedqr';
|
||||
}
|
||||
|
||||
// 4. Compact SeedQR (all hex)
|
||||
// 12 words = 16 bytes = 32 hex chars
|
||||
// 24 words = 32 bytes = 64 hex chars
|
||||
if (/^[0-9a-fA-F]+$/.test(trimmed) && (trimmed.length === 32 || trimmed.length === 64)) {
|
||||
return 'seedqr';
|
||||
}
|
||||
|
||||
// 5. Krux Base43 format (uses a specific character set)
|
||||
if (BASE43_CHARS_ONLY_REGEX.test(trimmed)) {
|
||||
return 'krux';
|
||||
}
|
||||
|
||||
// 6. Likely a plain text mnemonic (contains spaces)
|
||||
if (trimmed.includes(' ')) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
// 5. Default to text
|
||||
// 7. Default for anything else
|
||||
return 'text';
|
||||
}
|
||||
|
||||
@@ -29,8 +29,12 @@ function bytesToBase64(bytes: Uint8Array): string {
|
||||
* @private
|
||||
*/
|
||||
let sessionKey: CryptoKey | null = null;
|
||||
let keyCreatedAt = 0;
|
||||
let keyOperationCount = 0;
|
||||
const KEY_ALGORITHM = 'AES-GCM';
|
||||
const KEY_LENGTH = 256;
|
||||
const KEY_ROTATION_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_KEY_OPERATIONS = 1000; // Rotate after N operations
|
||||
|
||||
/**
|
||||
* An object containing encrypted data and necessary metadata for decryption.
|
||||
@@ -55,21 +59,39 @@ export interface EncryptedBlob {
|
||||
* This function must be called before any encryption or decryption can occur.
|
||||
* @returns A promise that resolves to the generated or existing CryptoKey.
|
||||
*/
|
||||
/**
|
||||
* Get or create session key with automatic rotation.
|
||||
* Key rotates every 5 minutes or after 1000 operations.
|
||||
*/
|
||||
export async function getSessionKey(): Promise<CryptoKey> {
|
||||
if (sessionKey) {
|
||||
return sessionKey;
|
||||
const now = Date.now();
|
||||
const shouldRotate =
|
||||
!sessionKey ||
|
||||
(now - keyCreatedAt) > KEY_ROTATION_INTERVAL ||
|
||||
keyOperationCount > MAX_KEY_OPERATIONS;
|
||||
|
||||
if (shouldRotate) {
|
||||
if (sessionKey) {
|
||||
// Note: CryptoKey cannot be explicitly zeroed, but dereferencing helps GC
|
||||
const elapsed = now - keyCreatedAt;
|
||||
console.debug?.(`Rotating session key (age: ${elapsed}ms, ops: ${keyOperationCount})`);
|
||||
sessionKey = null;
|
||||
}
|
||||
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: KEY_ALGORITHM,
|
||||
length: KEY_LENGTH,
|
||||
},
|
||||
false, // non-exportable
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
sessionKey = key;
|
||||
keyCreatedAt = now;
|
||||
keyOperationCount = 0;
|
||||
}
|
||||
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: KEY_ALGORITHM,
|
||||
length: KEY_LENGTH,
|
||||
},
|
||||
false, // non-exportable
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
sessionKey = key;
|
||||
return key;
|
||||
return sessionKey!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +100,10 @@ export async function getSessionKey(): Promise<CryptoKey> {
|
||||
* @returns A promise that resolves to an EncryptedBlob.
|
||||
*/
|
||||
export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
|
||||
if (!sessionKey) {
|
||||
const key = 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.');
|
||||
}
|
||||
|
||||
@@ -90,7 +115,7 @@ export async function encryptJsonToBlob<T>(data: T): Promise<EncryptedBlob> {
|
||||
name: KEY_ALGORITHM,
|
||||
iv: new Uint8Array(iv),
|
||||
},
|
||||
sessionKey,
|
||||
key,
|
||||
plaintext,
|
||||
);
|
||||
|
||||
@@ -108,7 +133,10 @@ 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> {
|
||||
if (!sessionKey) {
|
||||
const key = await getSessionKey(); // Ensures key exists and handles rotation
|
||||
keyOperationCount++; // Track operations for rotation
|
||||
|
||||
if (!key) {
|
||||
throw new Error('Session key not initialized or has been destroyed.');
|
||||
}
|
||||
if (blob.v !== 1 || blob.alg !== 'A256GCM') {
|
||||
@@ -123,7 +151,7 @@ export async function decryptBlobToJson<T>(blob: EncryptedBlob): Promise<T> {
|
||||
name: KEY_ALGORITHM,
|
||||
iv: new Uint8Array(iv),
|
||||
},
|
||||
sessionKey,
|
||||
key,
|
||||
new Uint8Array(ciphertext),
|
||||
);
|
||||
|
||||
@@ -137,6 +165,18 @@ export async function decryptBlobToJson<T>(blob: EncryptedBlob): Promise<T> {
|
||||
*/
|
||||
export function destroySessionKey(): void {
|
||||
sessionKey = null;
|
||||
keyOperationCount = 0;
|
||||
keyCreatedAt = 0;
|
||||
}
|
||||
|
||||
// Auto-clear session key when page becomes hidden
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
console.debug?.('Page hidden - clearing session key for security');
|
||||
destroySessionKey();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
48
src/main.tsx
48
src/main.tsx
@@ -1,24 +1,38 @@
|
||||
import './polyfills';
|
||||
|
||||
// Suppress OpenPGP.js AES cipher warnings
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
// Production: Disable all console output (prevents seed recovery via console history)
|
||||
if (import.meta.env.PROD) {
|
||||
console.log = () => { };
|
||||
console.error = () => { };
|
||||
console.warn = () => { };
|
||||
console.debug = () => { };
|
||||
console.info = () => { };
|
||||
console.trace = () => { };
|
||||
console.time = () => { };
|
||||
console.timeEnd = () => { };
|
||||
}
|
||||
|
||||
console.warn = (...args: any[]) => {
|
||||
const msg = args[0]?.toString() || '';
|
||||
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
// Development: Suppress OpenPGP.js AES cipher warnings
|
||||
if (import.meta.env.DEV) {
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
console.error = (...args: any[]) => {
|
||||
const msg = args[0]?.toString() || '';
|
||||
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||
return;
|
||||
}
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
console.warn = (...args: any[]) => {
|
||||
const msg = args[0]?.toString() || '';
|
||||
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
console.error = (...args: any[]) => {
|
||||
const msg = args[0]?.toString() || '';
|
||||
if (msg.includes('AES-CBC') || msg.includes('AES-CTR') || msg.includes('authentication')) {
|
||||
return;
|
||||
}
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
Reference in New Issue
Block a user