mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
252 lines
8.9 KiB
TypeScript
252 lines
8.9 KiB
TypeScript
// src/lib/krux.ts
|
|
// Krux KEF (Krux Encryption Format) implementation
|
|
import * as pako from 'pako';
|
|
import { base43Decode, base43Encode } from './base43';
|
|
import { getWalletFingerprint } from './bip32';
|
|
export const VERSIONS: Record<number, {
|
|
name: string;
|
|
compress: boolean;
|
|
auth: number;
|
|
}> = {
|
|
// We only implement the GCM versions as they are the only ones compatible with WebCrypto
|
|
20: { name: "AES-GCM", compress: false, auth: 4 },
|
|
21: { name: "AES-GCM +c", compress: true, auth: 4 },
|
|
};
|
|
|
|
const GCM_IV_LENGTH = 12;
|
|
|
|
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
|
// Create a new ArrayBuffer and copy the contents
|
|
const buffer = new ArrayBuffer(data.byteLength);
|
|
new Uint8Array(buffer).set(data);
|
|
return buffer;
|
|
}
|
|
|
|
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");
|
|
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);
|
|
const version = envelope[1 + lenId];
|
|
if (!VERSIONS[version]) {
|
|
throw new Error(`Unsupported KEF version: ${version}`);
|
|
}
|
|
|
|
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, labelBytes, version, iterations, payload };
|
|
}
|
|
|
|
import { pbkdf2HmacSha256 } from './pbkdf2';
|
|
import { entropyToMnemonic, mnemonicToEntropy } from './seedblend';
|
|
|
|
// ... (rest of the file is the same until KruxCipher)
|
|
|
|
export class KruxCipher {
|
|
private keyPromise: Promise<CryptoKey>;
|
|
|
|
constructor(passphrase: string, salt: Uint8Array, iterations: number) {
|
|
this.keyPromise = (async () => {
|
|
// Use pure-JS PBKDF2 implementation which has been validated against Krux's test vector
|
|
const derivedKeyBytes = await pbkdf2HmacSha256(passphrase, salt, iterations, 32);
|
|
|
|
// Import the derived bytes as an AES-GCM key
|
|
return crypto.subtle.importKey(
|
|
"raw",
|
|
toArrayBuffer(derivedKeyBytes),
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
["encrypt", "decrypt"]
|
|
);
|
|
})();
|
|
}
|
|
|
|
// 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}`);
|
|
|
|
let dataToEncrypt = plaintext;
|
|
if (v.compress) {
|
|
dataToEncrypt = pako.deflate(plaintext);
|
|
}
|
|
|
|
let ivBytes = iv ? new Uint8Array(iv) : crypto.getRandomValues(new Uint8Array(GCM_IV_LENGTH));
|
|
|
|
const key = await this.keyPromise;
|
|
const plaintextBuffer = toArrayBuffer(dataToEncrypt);
|
|
const ivBuffer = toArrayBuffer(ivBytes);
|
|
const tagLengthBits = v.auth * 8;
|
|
|
|
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 - v.auth);
|
|
const tag = encryptedBytes.slice(encryptedBytes.length - v.auth);
|
|
|
|
const combined = new Uint8Array(ivBytes.length + ciphertext.length + tag.length);
|
|
combined.set(ivBytes, 0);
|
|
combined.set(ciphertext, ivBytes.length);
|
|
combined.set(tag, ivBytes.length + ciphertext.length);
|
|
return combined;
|
|
}
|
|
|
|
async decrypt(payload: Uint8Array, version: number): Promise<Uint8Array> {
|
|
const v = VERSIONS[version];
|
|
if (!v) throw new Error(`Unsupported KEF version: ${version}`);
|
|
if (payload.length < GCM_IV_LENGTH + v.auth) throw new Error("Payload too short for AES-GCM");
|
|
|
|
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 {
|
|
const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
|
|
ciphertextWithTag.set(ciphertext, 0);
|
|
ciphertextWithTag.set(tag, ciphertext.length);
|
|
|
|
const decryptedBuffer = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: toArrayBuffer(iv), tagLength: v.auth * 8 }, key, toArrayBuffer(ciphertextWithTag)
|
|
);
|
|
|
|
let decrypted = new Uint8Array(decryptedBuffer);
|
|
|
|
if (v.compress) {
|
|
decrypted = pako.inflate(decrypted);
|
|
}
|
|
return decrypted;
|
|
} catch (error) {
|
|
console.error("Krux decryption internal error:", error);
|
|
throw new Error("Krux decryption failed - wrong passphrase or corrupted data");
|
|
}
|
|
}
|
|
}
|
|
|
|
export function hexToBytes(hex: string): Uint8Array {
|
|
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");
|
|
const bytes = new Uint8Array(cleaned.length / 2);
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
bytes[i] = parseInt(cleaned.substr(i * 2, 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
export async function decryptFromKrux(params: { kefData: string; passphrase: string; }): Promise<{ mnemonic: string; label: string; version: number; iterations: number }> {
|
|
const { kefData, passphrase } = params;
|
|
|
|
// STEP 1: Validate and decode data format (FIRST!)
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = hexToBytes(kefData);
|
|
} catch (e) {
|
|
try {
|
|
bytes = base43Decode(kefData);
|
|
} catch (e2) {
|
|
throw new Error("Invalid Krux data: Not a valid Hex or Base43 string.");
|
|
}
|
|
}
|
|
|
|
// STEP 2: Unwrap and validate envelope structure
|
|
let label: string, labelBytes: Uint8Array, version: number, iterations: number, payload: Uint8Array;
|
|
try {
|
|
const unwrapped = unwrap(bytes);
|
|
label = unwrapped.label;
|
|
labelBytes = unwrapped.labelBytes;
|
|
version = unwrapped.version;
|
|
iterations = unwrapped.iterations;
|
|
payload = unwrapped.payload;
|
|
} catch (e: any) {
|
|
throw new Error("Invalid Krux data: Not a valid Hex or Base43 string.");
|
|
}
|
|
|
|
// STEP 3: Check passphrase (only after data structure is validated)
|
|
if (!passphrase) {
|
|
throw new Error("Passphrase is required for Krux decryption");
|
|
}
|
|
|
|
// STEP 4: Decrypt
|
|
const cipher = new KruxCipher(passphrase, labelBytes, iterations);
|
|
const decrypted = await cipher.decrypt(payload, version);
|
|
const mnemonic = await entropyToMnemonic(decrypted);
|
|
|
|
return { mnemonic, label, version, iterations };
|
|
}
|
|
|
|
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;
|
|
}): Promise<{ kefBase43: string; label: string; version: number; iterations: number }> {
|
|
const { mnemonic, passphrase } = params;
|
|
|
|
if (!passphrase) throw new Error("Passphrase is required");
|
|
|
|
const label = getWalletFingerprint(mnemonic);
|
|
const iterations = 100000;
|
|
const version = 20;
|
|
|
|
const mnemonicBytes = await mnemonicToEntropy(mnemonic);
|
|
const cipher = new KruxCipher(passphrase, new TextEncoder().encode(label), iterations);
|
|
const payload = await cipher.encrypt(mnemonicBytes, version);
|
|
const kef = wrap(label, version, iterations, payload);
|
|
const kefBase43 = base43Encode(kef);
|
|
|
|
// Debug logging disabled in production to prevent seed recovery via console history
|
|
if (import.meta.env.DEV) {
|
|
console.debug('KEF encryption completed', { version, iterations });
|
|
}
|
|
|
|
return { kefBase43, label, version, iterations };
|
|
}
|
|
|
|
export function wrap(label: string, version: number, iterations: number, payload: Uint8Array): Uint8Array {
|
|
const labelBytes = new TextEncoder().encode(label);
|
|
const idLen = labelBytes.length;
|
|
|
|
// ADD THIS:
|
|
if (idLen > 252) {
|
|
throw new Error('Label too long');
|
|
}
|
|
|
|
// Convert iterations to 3 bytes (Big-Endian)
|
|
// Scale down if > 10000 (Krux format: stores scaled value)
|
|
let scaledIter: number;
|
|
if (iterations >= 10000 && iterations % 10000 === 0) {
|
|
// Divisible by 10000 - store scaled
|
|
scaledIter = Math.floor(iterations / 10000);
|
|
} else {
|
|
// Store as-is (handles edge cases like 10001)
|
|
scaledIter = iterations;
|
|
}
|
|
const iterBytes = new Uint8Array(3);
|
|
iterBytes[0] = (scaledIter >> 16) & 0xFF;
|
|
iterBytes[1] = (scaledIter >> 8) & 0xFF;
|
|
iterBytes[2] = scaledIter & 0xFF;
|
|
|
|
// Calculate total length
|
|
const totalLength = 1 + idLen + 1 + 3 + payload.length;
|
|
const envelope = new Uint8Array(totalLength);
|
|
|
|
let offset = 0;
|
|
envelope[offset++] = idLen;
|
|
envelope.set(labelBytes, offset);
|
|
offset += idLen;
|
|
envelope[offset++] = version;
|
|
envelope.set(iterBytes, offset);
|
|
offset += 3;
|
|
envelope.set(payload, offset);
|
|
|
|
return envelope;
|
|
}
|