mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-06 17:37:51 +08:00
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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user