mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
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:
189
src/lib/krux.test.ts
Normal file
189
src/lib/krux.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// Krux KEF tests using Bun test runner
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
encryptToKrux,
|
||||
decryptFromKrux,
|
||||
hexToBytes,
|
||||
bytesToHex,
|
||||
wrap,
|
||||
unwrap,
|
||||
KruxCipher
|
||||
} from './krux';
|
||||
|
||||
describe('Krux KEF Implementation', () => {
|
||||
// Test basic hex conversion
|
||||
test('hexToBytes and bytesToHex roundtrip', () => {
|
||||
const original = 'Hello, World!';
|
||||
const bytes = new TextEncoder().encode(original);
|
||||
const hex = bytesToHex(bytes);
|
||||
const back = hexToBytes(hex);
|
||||
expect(new TextDecoder().decode(back)).toBe(original);
|
||||
});
|
||||
|
||||
test('hexToBytes handles KEF: prefix', () => {
|
||||
const hex = '48656C6C6F';
|
||||
const withPrefix = `KEF:${hex}`;
|
||||
const bytes1 = hexToBytes(hex);
|
||||
const bytes2 = hexToBytes(withPrefix);
|
||||
expect(bytes2).toEqual(bytes1);
|
||||
});
|
||||
|
||||
test('hexToBytes rejects invalid hex', () => {
|
||||
expect(() => hexToBytes('12345')).toThrow('Hex string must have even length');
|
||||
expect(() => hexToBytes('12345G')).toThrow('Invalid hex string');
|
||||
});
|
||||
|
||||
// Test wrap/unwrap
|
||||
test('wrap and unwrap roundtrip', () => {
|
||||
const label = 'Test Label';
|
||||
const version = 20;
|
||||
const iterations = 200000;
|
||||
const payload = new TextEncoder().encode('test payload');
|
||||
|
||||
const wrapped = wrap(label, version, iterations, payload);
|
||||
const unwrapped = unwrap(wrapped);
|
||||
|
||||
expect(unwrapped.label).toBe(label);
|
||||
expect(unwrapped.version).toBe(version);
|
||||
expect(unwrapped.iterations).toBe(iterations);
|
||||
expect(unwrapped.payload).toEqual(payload);
|
||||
});
|
||||
|
||||
test('wrap rejects label too long', () => {
|
||||
const longLabel = 'a'.repeat(253); // 253 > 252 max
|
||||
const payload = new Uint8Array([1, 2, 3]);
|
||||
|
||||
expect(() => wrap(longLabel, 20, 10000, payload))
|
||||
.toThrow('Label too long');
|
||||
});
|
||||
|
||||
test('wrap accepts empty label', () => {
|
||||
const payload = new Uint8Array([1, 2, 3]);
|
||||
const wrapped = wrap('', 20, 10000, payload);
|
||||
const unwrapped = unwrap(wrapped);
|
||||
expect(unwrapped.label).toBe('');
|
||||
expect(unwrapped.version).toBe(20);
|
||||
expect(unwrapped.iterations).toBe(10000);
|
||||
expect(unwrapped.payload).toEqual(payload);
|
||||
});
|
||||
|
||||
test('unwrap rejects invalid envelope', () => {
|
||||
expect(() => unwrap(new Uint8Array([1, 2, 3]))).toThrow('Invalid KEF envelope: too short');
|
||||
|
||||
// Label length too large (253 > 252)
|
||||
expect(() => unwrap(new Uint8Array([253, 20, 0, 0, 100]))).toThrow('Invalid label length');
|
||||
|
||||
// Empty label (lenId=0) is valid, but need enough data for version+iterations
|
||||
// Create a valid envelope with empty label: [0, version, iter1, iter2, iter3, payload...]
|
||||
const emptyLabelEnvelope = new Uint8Array([0, 20, 0, 0, 100, 1, 2, 3]);
|
||||
const unwrapped = unwrap(emptyLabelEnvelope);
|
||||
expect(unwrapped.label).toBe('');
|
||||
expect(unwrapped.version).toBe(20);
|
||||
});
|
||||
|
||||
// Test encryption/decryption
|
||||
test('encryptToKrux and decryptFromKrux roundtrip', async () => {
|
||||
const mnemonic = 'test test test test test test test test test test test junk';
|
||||
const passphrase = 'secure-passphrase';
|
||||
const label = 'Test Seed';
|
||||
const iterations = 10000;
|
||||
|
||||
const encrypted = await encryptToKrux({
|
||||
mnemonic,
|
||||
passphrase,
|
||||
label,
|
||||
iterations,
|
||||
version: 20,
|
||||
});
|
||||
|
||||
expect(encrypted.kefHex).toMatch(/^[0-9A-F]+$/);
|
||||
expect(encrypted.label).toBe(label);
|
||||
expect(encrypted.iterations).toBe(iterations);
|
||||
expect(encrypted.version).toBe(20);
|
||||
|
||||
const decrypted = await decryptFromKrux({
|
||||
kefHex: encrypted.kefHex,
|
||||
passphrase,
|
||||
});
|
||||
|
||||
expect(decrypted.mnemonic).toBe(mnemonic);
|
||||
expect(decrypted.label).toBe(label);
|
||||
expect(decrypted.iterations).toBe(iterations);
|
||||
expect(decrypted.version).toBe(20);
|
||||
});
|
||||
|
||||
test('encryptToKrux requires passphrase', async () => {
|
||||
await expect(encryptToKrux({
|
||||
mnemonic: 'test',
|
||||
passphrase: '',
|
||||
})).rejects.toThrow('Passphrase is required');
|
||||
});
|
||||
|
||||
test('decryptFromKrux requires passphrase', async () => {
|
||||
await expect(decryptFromKrux({
|
||||
kefHex: '123456',
|
||||
passphrase: '',
|
||||
})).rejects.toThrow('Passphrase is required');
|
||||
});
|
||||
|
||||
test('wrong passphrase fails decryption', async () => {
|
||||
const mnemonic = 'test mnemonic';
|
||||
const passphrase = 'correct-passphrase';
|
||||
|
||||
const encrypted = await encryptToKrux({
|
||||
mnemonic,
|
||||
passphrase,
|
||||
});
|
||||
|
||||
await expect(decryptFromKrux({
|
||||
kefHex: encrypted.kefHex,
|
||||
passphrase: 'wrong-passphrase',
|
||||
})).rejects.toThrow(/Krux decryption failed/);
|
||||
});
|
||||
|
||||
// Test KruxCipher class directly
|
||||
test('KruxCipher encrypt/decrypt roundtrip', async () => {
|
||||
const cipher = new KruxCipher('passphrase', 'salt', 10000);
|
||||
const plaintext = new TextEncoder().encode('secret message');
|
||||
|
||||
const encrypted = await cipher.encrypt(plaintext);
|
||||
const decrypted = await cipher.decrypt(encrypted, 20);
|
||||
|
||||
expect(new TextDecoder().decode(decrypted)).toBe('secret message');
|
||||
});
|
||||
|
||||
test('KruxCipher rejects unsupported version', async () => {
|
||||
const cipher = new KruxCipher('passphrase', 'salt', 10000);
|
||||
const plaintext = new Uint8Array([1, 2, 3]);
|
||||
|
||||
await expect(cipher.encrypt(plaintext, 99)).rejects.toThrow('Unsupported KEF version');
|
||||
await expect(cipher.decrypt(new Uint8Array(50), 99)).rejects.toThrow('Unsupported KEF version');
|
||||
});
|
||||
|
||||
test('KruxCipher rejects short payload', async () => {
|
||||
const cipher = new KruxCipher('passphrase', 'salt', 10000);
|
||||
// Version 20: IV (12) + auth (4) = 16 bytes minimum
|
||||
const shortPayload = new Uint8Array(15); // Too short for IV + GCM tag (needs at least 16)
|
||||
|
||||
await expect(cipher.decrypt(shortPayload, 20)).rejects.toThrow('Payload too short for AES-GCM');
|
||||
});
|
||||
|
||||
test('iterations scaling works correctly', () => {
|
||||
// Test that iterations are scaled properly when divisible by 10000
|
||||
const label = 'Test';
|
||||
const version = 20;
|
||||
const payload = new Uint8Array([1, 2, 3]);
|
||||
|
||||
// 200000 should be scaled to 20 in the envelope
|
||||
const wrapped1 = wrap(label, version, 200000, payload);
|
||||
expect(wrapped1[6]).toBe(0); // 200000 / 10000 = 20
|
||||
expect(wrapped1[7]).toBe(0);
|
||||
expect(wrapped1[8]).toBe(20);
|
||||
|
||||
// 10001 should not be scaled
|
||||
const wrapped2 = wrap(label, version, 10001, payload);
|
||||
const iterStart = 2 + label.length;
|
||||
const iters = (wrapped2[iterStart] << 16) | (wrapped2[iterStart + 1] << 8) | wrapped2[iterStart + 2];
|
||||
expect(iters).toBe(10001);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user