fix(krux): add decompression for Base43 QR codes

Implements zlib decompression for encrypted Krux QR codes, resolving the final decryption failure.

- Adds `pako` as a dependency to handle zlib (deflate/inflate) operations in JavaScript.
- Overhauls `krux.ts` to be a more complete port of the `kef.py` logic.
- `VERSIONS` constant is updated to include `compress` flags.
- `KruxCipher.decrypt` now checks the KEF version and uses `pako.inflate` to decompress the plaintext after decryption, matching the behavior of the official Krux implementation.
- This fixes the bug where correctly identified and decoded Krux payloads still failed to produce a valid mnemonic.
This commit is contained in:
LC mac
2026-02-04 13:48:07 +08:00
parent e25cd9ebf9
commit 9c84f13f2a
3 changed files with 65 additions and 216 deletions

View File

@@ -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=="],

View File

@@ -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"

View File

@@ -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<number, {
name: string;
compress?: boolean;
auth: number; // GCM tag length (4 for version 20, full 16 for v1?)
compress: boolean;
auth: number;
}> = {
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<CryptoKey>;
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<Uint8Array> {
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<Uint8Array> {
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 };
}
}