From 7d48d2ade2046717ba10d2a85f7ae95f8d0dda46 Mon Sep 17 00:00:00 2001 From: LC mac Date: Wed, 4 Feb 2026 15:02:48 +0800 Subject: [PATCH] fix(krux): use raw label bytes as PBKDF2 salt Fixes the final decryption failure for Krux QR codes by correcting the salt used in key derivation. - The KEF `unwrap` function now returns the raw `labelBytes` from the envelope. - `KruxCipher` constructor now accepts these raw bytes and uses them directly as the salt for PBKDF2. - This resolves a subtle bug where the string representation of the label was being incorrectly re-encoded, leading to an invalid key and failed decryption, even with the correct password. --- src/lib/krux.ts | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/src/lib/krux.ts b/src/lib/krux.ts index c224987..8d2b57a 100644 --- a/src/lib/krux.ts +++ b/src/lib/krux.ts @@ -20,7 +20,7 @@ function toArrayBuffer(data: Uint8Array): ArrayBuffer { return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); } -export function unwrap(envelope: Uint8Array): { label: string; version: number; iterations: number; payload: Uint8Array } { +export function unwrap(envelope: Uint8Array): { label: string; labelBytes: Uint8Array, 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"); @@ -37,18 +37,18 @@ export function unwrap(envelope: Uint8Array): { label: string; version: number; 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 }; + return { label, labelBytes, version, iterations, payload }; } export class KruxCipher { private keyPromise: Promise; - constructor(passphrase: string, salt: string, iterations: number) { + constructor(passphrase: string, salt: Uint8Array, iterations: number) { const encoder = new TextEncoder(); this.keyPromise = (async () => { const passphraseBuffer = toArrayBuffer(encoder.encode(passphrase)); const baseKey = await crypto.subtle.importKey("raw", passphraseBuffer, { name: "PBKDF2" }, false, ["deriveKey"]); - const saltBuffer = toArrayBuffer(encoder.encode(salt)); + const saltBuffer = toArrayBuffer(salt); // Use the raw bytes directly 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"] @@ -56,6 +56,7 @@ export class KruxCipher { })(); } + // Encrypt function is unused in SeedBlender, but kept for completeness async encrypt(plaintext: Uint8Array, version = 20, iv?: Uint8Array): Promise { const v = VERSIONS[version]; if (!v) throw new Error(`Unsupported KEF version: ${version}`); @@ -106,7 +107,6 @@ export class KruxCipher { let decrypted = new Uint8Array(decryptedBuffer); - // Decompress if the version requires it if (v.compress) { decrypted = pako.inflate(decrypted); } @@ -129,21 +129,6 @@ export function hexToBytes(hex: string): Uint8Array { return bytes; } -export function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase(); -} - -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"); - - 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 }; -} - 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"); @@ -159,10 +144,11 @@ export async function decryptFromKrux(params: { kefData: string; passphrase: str } } - const { label, version, iterations, payload } = unwrap(bytes); - const cipher = new KruxCipher(passphrase, label, iterations); + const { label, labelBytes, version, iterations, payload } = unwrap(bytes); + // The salt for PBKDF2 is the raw label bytes from the envelope + const cipher = new KruxCipher(passphrase, labelBytes, 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 +}