diff --git a/bun.lock b/bun.lock index ebcf2f2..d2073e5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,11 @@ "": { "name": "seedpgp-web", "dependencies": { + "@types/pako": "^2.0.4", "html5-qrcode": "^2.3.8", "lucide-react": "^0.462.0", "openpgp": "^6.3.0", + "pako": "^2.1.0", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -202,6 +204,8 @@ "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], + "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], @@ -358,6 +362,8 @@ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], diff --git a/package.json b/package.json index 57a7b54..cb4d60a 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@types/pako": "^2.0.4", "html5-qrcode": "^2.3.8", "lucide-react": "^0.462.0", "openpgp": "^6.3.0", + "pako": "^2.1.0", "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/src/lib/krux.ts b/src/lib/krux.ts index 7310634..cc72413 100644 --- a/src/lib/krux.ts +++ b/src/lib/krux.ts @@ -1,90 +1,55 @@ // src/lib/krux.ts // Krux KEF (Krux Encryption Format) implementation -// Compatible with Krux firmware (AES-GCM, label as salt, hex QR) -// Currently implements version 20 (AES-GCM without compression) -// Version 21 (AES-GCM +c) support can be added later with compression +import * as pako from 'pako'; +import { base43Decode } from './base43'; // KEF version definitions (matches Python reference) export const VERSIONS: Record = { - 20: { name: "AES-GCM", auth: 4 }, - // Version 21 would be: { name: "AES-GCM +c", compress: true, auth: 4 } + // Versions from kef.py - only GCM modes are relevant for our WebCrypto implementation + 20: { name: "AES-GCM", compress: false, auth: 4 }, + 21: { name: "AES-GCM +c", compress: true, auth: 4 }, }; -// IV length for GCM mode const GCM_IV_LENGTH = 12; -/** - * Convert data to a proper ArrayBuffer for Web Crypto API. - * Ensures it's not a SharedArrayBuffer. - */ function toArrayBuffer(data: Uint8Array): ArrayBuffer { - // Always create a new ArrayBuffer and copy the data const buffer = new ArrayBuffer(data.length); new Uint8Array(buffer).set(data); return buffer; } - -/** - * Wrap data into KEF envelope format (matches Python exactly) - * Format: [1 byte label length][label bytes][1 byte version][3 bytes iterations][payload] - */ export function wrap(label: string, version: number, iterations: number, payload: Uint8Array): Uint8Array { const labelBytes = new TextEncoder().encode(label); if (!(0 <= labelBytes.length && labelBytes.length <= 252)) { throw new Error("Label too long (max 252 bytes)"); } - const lenId = new Uint8Array([labelBytes.length]); const versionByte = new Uint8Array([version]); - let itersBytes = new Uint8Array(3); - // Krux firmware expects iterations in multiples of 10000 when possible if (iterations % 10000 === 0) { const scaled = iterations / 10000; - if (!(1 <= scaled && scaled <= 10000)) { - throw new Error("Iterations out of scaled range"); - } + if (!(1 <= scaled && scaled <= 10000)) throw new Error("Iterations out of scaled range"); itersBytes[0] = (scaled >> 16) & 0xff; itersBytes[1] = (scaled >> 8) & 0xff; itersBytes[2] = scaled & 0xff; } else { - if (!(10000 < iterations && iterations < 2**24)) { - throw new Error("Iterations out of range"); - } + if (!(10000 < iterations && iterations < 2**24)) throw new Error("Iterations out of range"); itersBytes[0] = (iterations >> 16) & 0xff; itersBytes[1] = (iterations >> 8) & 0xff; itersBytes[2] = iterations & 0xff; } - return new Uint8Array([...lenId, ...labelBytes, ...versionByte, ...itersBytes, ...payload]); } -/** - * Unwrap KEF envelope to extract components (matches Python exactly) - */ -export function unwrap(envelope: Uint8Array): { - label: string; - version: number; - iterations: number; - payload: Uint8Array -} { - if (envelope.length < 5) { - throw new Error("Invalid KEF envelope: too short"); - } - +export function unwrap(envelope: Uint8Array): { label: string; version: number; iterations: number; payload: Uint8Array } { + if (envelope.length < 5) throw new Error("Invalid KEF envelope: too short"); const lenId = envelope[0]; - if (!(0 <= lenId && lenId <= 252)) { - throw new Error("Invalid label length in KEF envelope"); - } - - if (1 + lenId + 4 > envelope.length) { - throw new Error("Invalid KEF envelope: insufficient data"); - } + if (!(0 <= lenId && lenId <= 252)) throw new Error("Invalid label length in KEF envelope"); + if (1 + lenId + 4 > envelope.length) throw new Error("Invalid KEF envelope: insufficient data"); const labelBytes = envelope.subarray(1, 1 + lenId); const label = new TextDecoder().decode(labelBytes); @@ -93,101 +58,47 @@ export function unwrap(envelope: Uint8Array): { const iterStart = 2 + lenId; let iters = (envelope[iterStart] << 16) | (envelope[iterStart + 1] << 8) | envelope[iterStart + 2]; const iterations = iters <= 10000 ? iters * 10000 : iters; - const payload = envelope.subarray(5 + lenId); - return { label, version, iterations, payload }; } -/** - * Krux Cipher class for AES-GCM encryption/decryption - */ export class KruxCipher { private keyPromise: Promise; constructor(passphrase: string, salt: string, iterations: number) { const encoder = new TextEncoder(); this.keyPromise = (async () => { - // Import passphrase as raw key material - const passphraseBytes = encoder.encode(passphrase); - const passphraseBuffer = toArrayBuffer(passphraseBytes); - - const baseKey = await crypto.subtle.importKey( - "raw", - passphraseBuffer, - { name: "PBKDF2" }, - false, - ["deriveKey"] - ); - - // Derive AES-GCM key using PBKDF2 - const saltBytes = encoder.encode(salt); - const saltBuffer = toArrayBuffer(saltBytes); - + const passphraseBuffer = toArrayBuffer(encoder.encode(passphrase)); + const baseKey = await crypto.subtle.importKey("raw", passphraseBuffer, { name: "PBKDF2" }, false, ["deriveKey"]); + const saltBuffer = toArrayBuffer(encoder.encode(salt)); return crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: saltBuffer, - iterations: Math.max(1, iterations), - hash: "SHA-256" - }, - baseKey, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"] + { name: "PBKDF2", salt: saltBuffer, iterations: Math.max(1, iterations), hash: "SHA-256" }, + baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); })(); } - /** - * Encrypt plaintext using AES-GCM - */ async encrypt(plaintext: Uint8Array, version = 20, iv?: Uint8Array): Promise { const v = VERSIONS[version]; - if (!v) { - throw new Error(`Unsupported KEF version: ${version}`); + if (!v) throw new Error(`Unsupported KEF version: ${version}`); + + let dataToEncrypt = plaintext; + if (v.compress) { + dataToEncrypt = pako.deflate(plaintext); } - // Note: No compression for version 20 - // For version 21, we would add compression here - - // Ensure ivBytes is a fresh Uint8Array with its own ArrayBuffer (not SharedArrayBuffer) - let ivBytes: Uint8Array; - if (iv) { - // Copy the iv to ensure we have our own buffer - ivBytes = new Uint8Array(iv.length); - ivBytes.set(iv); - } else { - // Create new random IV with a proper ArrayBuffer - ivBytes = new Uint8Array(GCM_IV_LENGTH); - crypto.getRandomValues(ivBytes); - } + let ivBytes = iv ? new Uint8Array(iv) : crypto.getRandomValues(new Uint8Array(GCM_IV_LENGTH)); const key = await this.keyPromise; - const plaintextBuffer = toArrayBuffer(plaintext); + const plaintextBuffer = toArrayBuffer(dataToEncrypt); const ivBuffer = toArrayBuffer(ivBytes); - - // Use auth length from version definition (in bytes, convert to bits) const tagLengthBits = v.auth * 8; - const encrypted = await crypto.subtle.encrypt( - { - name: "AES-GCM", - iv: ivBuffer, - tagLength: tagLengthBits - }, - key, - plaintextBuffer - ); - - // For GCM, encrypted result includes ciphertext + tag - // Separate ciphertext and tag - const authBytes = v.auth; + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv: ivBuffer, tagLength: tagLengthBits }, key, plaintextBuffer); const encryptedBytes = new Uint8Array(encrypted); - const ciphertext = encryptedBytes.slice(0, encryptedBytes.length - authBytes); - const tag = encryptedBytes.slice(encryptedBytes.length - authBytes); + const ciphertext = encryptedBytes.slice(0, encryptedBytes.length - v.auth); + const tag = encryptedBytes.slice(encryptedBytes.length - v.auth); - // Combine IV + ciphertext + tag (matches Python format) const combined = new Uint8Array(ivBytes.length + ciphertext.length + tag.length); combined.set(ivBytes, 0); combined.set(ciphertext, ivBytes.length); @@ -195,73 +106,42 @@ export class KruxCipher { return combined; } - /** - * Decrypt payload using AES-GCM - */ async decrypt(payload: Uint8Array, version: number): Promise { const v = VERSIONS[version]; - if (!v) { - throw new Error(`Unsupported KEF version: ${version}`); - } + if (!v) throw new Error(`Unsupported KEF version: A${version}`); + if (payload.length < GCM_IV_LENGTH + v.auth) throw new Error("Payload too short for AES-GCM"); - const ivLen = GCM_IV_LENGTH; - const authBytes = v.auth; - - // Payload is IV + ciphertext + tag - if (payload.length < ivLen + authBytes) { - throw new Error("Payload too short for AES-GCM"); - } - - // Extract IV, ciphertext, and tag - const iv = payload.slice(0, ivLen); - const ciphertext = payload.slice(ivLen, payload.length - authBytes); - const tag = payload.slice(payload.length - authBytes); + const iv = payload.slice(0, GCM_IV_LENGTH); + const ciphertext = payload.slice(GCM_IV_LENGTH, payload.length - v.auth); + const tag = payload.slice(payload.length - v.auth); const key = await this.keyPromise; try { - // For Web Crypto, we need to combine ciphertext + tag const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length); ciphertextWithTag.set(ciphertext, 0); ciphertextWithTag.set(tag, ciphertext.length); - const ciphertextBuffer = toArrayBuffer(ciphertextWithTag); - const ivBuffer = toArrayBuffer(iv); - - const decrypted = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: ivBuffer, - tagLength: authBytes * 8 - }, - key, - ciphertextBuffer + const decryptedBuffer = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv), tagLength: v.auth * 8 }, key, toArrayBuffer(ciphertextWithTag) ); - return new Uint8Array(decrypted); + let decrypted = new Uint8Array(decryptedBuffer); + if (v.compress) { + decrypted = pako.inflate(decrypted); + } + return decrypted; } catch (error) { - // Web Crypto throws generic errors for decryption failure - // Convert to user-friendly message + console.error("Krux decryption internal error:", error); throw new Error("Krux decryption failed - wrong passphrase or corrupted data"); } } } -/** - * Convert hex string to bytes - */ export function hexToBytes(hex: string): Uint8Array { - // Remove any whitespace and optional KEF: prefix const cleaned = hex.trim().replace(/\s/g, '').replace(/^KEF:/i, ''); - - if (!/^[0-9a-fA-F]+$/.test(cleaned)) { - throw new Error("Invalid hex string"); - } - - if (cleaned.length % 2 !== 0) { - throw new Error("Hex string must have even length"); - } - + if (!/^[0-9a-fA-F]+$/.test(cleaned)) throw new Error("Invalid hex string"); + if (cleaned.length % 2 !== 0) throw new Error("Hex string must have even length"); const bytes = new Uint8Array(cleaned.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(cleaned.substr(i * 2, 2), 16); @@ -269,79 +149,40 @@ export function hexToBytes(hex: string): Uint8Array { return bytes; } -/** - * Convert bytes to hex string - */ export function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0')) - .join('') - .toUpperCase(); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase(); } -/** - * Encrypt mnemonic to KEF format - */ -export async function encryptToKrux(params: { - mnemonic: string; - passphrase: string; - label?: string; - iterations?: number; - version?: number; -}): Promise<{ kefHex: string; label: string; version: number; iterations: number }> { - const label = params.label || "Seed Backup"; - const iterations = params.iterations || 200000; - const version = params.version || 20; +export async function encryptToKrux(params: { mnemonic: string; passphrase: string; label?: string; iterations?: number; version?: number; }): Promise<{ kefHex: string; label: string; version: number; iterations: number }> { + const { mnemonic, passphrase, label = "Seed Backup", iterations = 200000, version = 21 } = params; + if (!passphrase) throw new Error("Passphrase is required for Krux encryption"); - if (!params.passphrase) { - throw new Error("Passphrase is required for Krux encryption"); - } - - const mnemonicBytes = new TextEncoder().encode(params.mnemonic); - const cipher = new KruxCipher(params.passphrase, label, iterations); + const mnemonicBytes = new TextEncoder().encode(mnemonic); + const cipher = new KruxCipher(passphrase, label, iterations); const payload = await cipher.encrypt(mnemonicBytes, version); const kef = wrap(label, version, iterations, payload); - - return { - kefHex: bytesToHex(kef), - label, - version, - iterations - }; + return { kefHex: bytesToHex(kef), label, version, iterations }; } -import { base43Decode } from './base43'; - -// ... (rest of the file until decryptFromKrux) - -/** - * Decrypt KEF data (Hex or Base43) to mnemonic - */ -export async function decryptFromKrux(params: { - kefData: string; - passphrase: string; -}): Promise<{ mnemonic: string; label: string; version: number; iterations: number }> { - if (!params.passphrase) { - throw new Error("Passphrase is required for Krux decryption"); - } +export async function decryptFromKrux(params: { kefData: string; passphrase: string; }): Promise<{ mnemonic: string; label: string; version: number; iterations: number }> { + const { kefData, passphrase } = params; + if (!passphrase) throw new Error("Passphrase is required for Krux decryption"); let bytes: Uint8Array; try { - // First, try to decode as hex - bytes = hexToBytes(params.kefData); + bytes = hexToBytes(kefData); } catch (e) { - // If hex fails, try to decode as Base43 try { - bytes = base43Decode(params.kefData); + bytes = base43Decode(kefData); } catch (e2) { throw new Error("Invalid Krux data: Not a valid Hex or Base43 string."); } } const { label, version, iterations, payload } = unwrap(bytes); - const cipher = new KruxCipher(params.passphrase, label, iterations); + const cipher = new KruxCipher(passphrase, label, iterations); const decrypted = await cipher.decrypt(payload, version); const mnemonic = new TextDecoder().decode(decrypted); return { mnemonic, label, version, iterations }; -} \ No newline at end of file +}