docs: enhance documentation with threat model, limitations, air-gapped guidance

- Update version to v1.4.4
- Add explicit threat model documentation
- Document known limitations prominently
- Include air-gapped usage recommendations
- Polish all documentation for clarity and examples
- Update README, DEVELOPMENT.md, GEMINI.md, RECOVERY_PLAYBOOK.md
This commit is contained in:
LC mac
2026-02-03 02:24:59 +08:00
parent a7ab757669
commit 4353ec0cc2
10 changed files with 1208 additions and 333 deletions

331
src/lib/krux.ts Normal file
View File

@@ -0,0 +1,331 @@
// 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
// 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?)
}> = {
20: { name: "AES-GCM", auth: 4 },
// Version 21 would be: { 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");
}
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");
}
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");
}
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];
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);
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"]
);
})();
}
/**
* 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}`);
}
// 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);
}
const key = await this.keyPromise;
const plaintextBuffer = toArrayBuffer(plaintext);
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 encryptedBytes = new Uint8Array(encrypted);
const ciphertext = encryptedBytes.slice(0, encryptedBytes.length - authBytes);
const tag = encryptedBytes.slice(encryptedBytes.length - authBytes);
// 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);
combined.set(tag, ivBytes.length + ciphertext.length);
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}`);
}
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 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
);
return new Uint8Array(decrypted);
} catch (error) {
// Web Crypto throws generic errors for decryption failure
// Convert to user-friendly message
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");
}
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;
}
/**
* 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();
}
/**
* 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;
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 payload = await cipher.encrypt(mnemonicBytes, version);
const kef = wrap(label, version, iterations, payload);
return {
kefHex: bytesToHex(kef),
label,
version,
iterations
};
}
/**
* Decrypt KEF hex to mnemonic
*/
export async function decryptFromKrux(params: {
kefHex: string;
passphrase: string;
}): Promise<{ mnemonic: string; label: string; version: number; iterations: number }> {
if (!params.passphrase) {
throw new Error("Passphrase is required for Krux decryption");
}
const bytes = hexToBytes(params.kefHex);
const { label, version, iterations, payload } = unwrap(bytes);
const cipher = new KruxCipher(params.passphrase, label, iterations);
const decrypted = await cipher.decrypt(payload, version);
const mnemonic = new TextDecoder().decode(decrypted);
return { mnemonic, label, version, iterations };
}