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.
This commit is contained in:
LC mac
2026-02-04 15:02:48 +08:00
parent 857f075e26
commit 7d48d2ade2

View File

@@ -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<CryptoKey>;
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<Uint8Array> {
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 };
}
}