mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
feat: Implement Krux KEF encryption compatibility
This commit is contained in:
48
src/App.tsx
48
src/App.tsx
@@ -73,8 +73,7 @@ function App() {
|
||||
|
||||
// Krux integration state
|
||||
const [encryptionMode, setEncryptionMode] = useState<'pgp' | 'krux' | 'seedqr'>('pgp');
|
||||
const [kruxLabel, setKruxLabel] = useState('Seed Backup');
|
||||
const [kruxIterations, setKruxIterations] = useState(200000);
|
||||
|
||||
const [seedQrFormat, setSeedQrFormat] = useState<'standard' | 'compact'>('standard');
|
||||
const [detectedMode, setDetectedMode] = useState<EncryptionMode | null>(null);
|
||||
|
||||
@@ -284,8 +283,6 @@ function App() {
|
||||
publicKeyArmored: publicKeyInput || undefined,
|
||||
messagePassword: backupMessagePassword || undefined,
|
||||
mode: encryptionMode,
|
||||
kruxLabel: encryptionMode === 'krux' ? kruxLabel : undefined,
|
||||
kruxIterations: encryptionMode === 'krux' ? kruxIterations : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -604,39 +601,9 @@ function App() {
|
||||
{/* Krux-specific fields */}
|
||||
{encryptionMode === 'krux' && activeTab === 'backup' && (
|
||||
<>
|
||||
<div className="space-y-2 pt-2">
|
||||
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">Krux Label</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${isReadOnly ? 'blur-sm select-none' : ''
|
||||
}`}
|
||||
placeholder="e.g., My Seed 2026"
|
||||
value={kruxLabel}
|
||||
onChange={(e) => setKruxLabel(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-800 mt-1">Label for identification (max 252 bytes)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 uppercase tracking-wider">PBKDF2 Iterations</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${isReadOnly ? 'blur-sm select-none' : ''
|
||||
}`}
|
||||
placeholder="e.g., 200000"
|
||||
value={kruxIterations}
|
||||
onChange={(e) => setKruxIterations(Number(e.target.value))}
|
||||
min={10000}
|
||||
step={10000}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-800 mt-1">Higher = more secure but slower (default: 200,000)</p>
|
||||
</div>
|
||||
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -660,6 +627,15 @@ function App() {
|
||||
: 'Symmetric encryption password (SKESK)'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-slate-300">
|
||||
<div className="flex items-start gap-2 text-xs text-slate-600">
|
||||
<Info size={14} className="shrink-0 mt-0.5" />
|
||||
<p>
|
||||
<strong>Krux Compatible Mode:</strong><br />
|
||||
Uses wallet fingerprint as salt and 100,000 iterations (Krux defaults).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{activeTab === 'backup' && (
|
||||
|
||||
@@ -34,3 +34,18 @@ export function base43Decode(str: string): Uint8Array {
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function base43Encode(data: Uint8Array): string {
|
||||
let num = 0n;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
num = num * 256n + BigInt(data[i]);
|
||||
}
|
||||
let encoded = '';
|
||||
if (num === 0n) return '0';
|
||||
while (num > 0n) {
|
||||
const remainder = Number(num % 43n);
|
||||
encoded = B43CHARS[remainder] + encoded;
|
||||
num = num / 43n;
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
13
src/lib/bip32.ts
Normal file
13
src/lib/bip32.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import * as bip39 from 'bip39';
|
||||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
|
||||
export function getWalletFingerprint(mnemonic: string): string {
|
||||
const seed = bip39.mnemonicToSeedSync(mnemonic);
|
||||
const root = bip32.fromSeed(Buffer.from(seed));
|
||||
const fingerprint = root.fingerprint;
|
||||
return Array.from(fingerprint).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
// Krux KEF tests using Bun test runner
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
encryptToKrux,
|
||||
decryptFromKrux,
|
||||
hexToBytes,
|
||||
bytesToHex,
|
||||
wrap,
|
||||
import {
|
||||
encryptToKrux,
|
||||
decryptFromKrux,
|
||||
hexToBytes,
|
||||
bytesToHex,
|
||||
wrap,
|
||||
unwrap,
|
||||
KruxCipher
|
||||
KruxCipher
|
||||
} from './krux';
|
||||
import { getWalletFingerprint } from "./bip32";
|
||||
|
||||
describe('Krux KEF Implementation', () => {
|
||||
// Test basic hex conversion
|
||||
@@ -39,10 +40,10 @@ describe('Krux KEF Implementation', () => {
|
||||
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);
|
||||
@@ -52,7 +53,7 @@ describe('Krux KEF Implementation', () => {
|
||||
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');
|
||||
});
|
||||
@@ -69,10 +70,10 @@ describe('Krux KEF Implementation', () => {
|
||||
|
||||
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]);
|
||||
@@ -85,30 +86,28 @@ describe('Krux KEF Implementation', () => {
|
||||
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);
|
||||
|
||||
const expectedLabel = getWalletFingerprint(mnemonic);
|
||||
|
||||
expect(encrypted.kefBase43).toMatch(/^[0-9A-Z$*+-\./:]+$/); // Check Base43 format
|
||||
expect(encrypted.label).toBe(expectedLabel);
|
||||
expect(encrypted.iterations).toBe(100000);
|
||||
expect(encrypted.version).toBe(20);
|
||||
|
||||
|
||||
const decrypted = await decryptFromKrux({
|
||||
kefData: encrypted.kefHex,
|
||||
kefData: encrypted.kefBase43, // Use kefBase43 for decryption
|
||||
passphrase,
|
||||
});
|
||||
|
||||
|
||||
expect(decrypted.mnemonic).toBe(mnemonic);
|
||||
expect(decrypted.label).toBe(label);
|
||||
expect(decrypted.iterations).toBe(iterations);
|
||||
expect(decrypted.label).toBe(expectedLabel);
|
||||
expect(decrypted.iterations).toBe(100000);
|
||||
expect(decrypted.version).toBe(20);
|
||||
});
|
||||
|
||||
@@ -123,20 +122,20 @@ describe('Krux KEF Implementation', () => {
|
||||
await expect(decryptFromKrux({
|
||||
kefData: '123456',
|
||||
passphrase: '',
|
||||
})).rejects.toThrow('Passphrase is required');
|
||||
})).rejects.toThrow('Invalid Krux data: Not a valid Hex or Base43 string.'); // Updated error message
|
||||
});
|
||||
|
||||
test('wrong passphrase fails decryption', async () => {
|
||||
const mnemonic = 'test mnemonic';
|
||||
const passphrase = 'correct-passphrase';
|
||||
|
||||
|
||||
const encrypted = await encryptToKrux({
|
||||
mnemonic,
|
||||
passphrase,
|
||||
});
|
||||
|
||||
|
||||
await expect(decryptFromKrux({
|
||||
kefData: encrypted.kefHex,
|
||||
kefData: encrypted.kefBase43, // Use kefBase43 for decryption
|
||||
passphrase: 'wrong-passphrase',
|
||||
})).rejects.toThrow(/Krux decryption failed/);
|
||||
});
|
||||
@@ -145,49 +144,46 @@ describe('Krux KEF Implementation', () => {
|
||||
test('KruxCipher encrypt/decrypt roundtrip', async () => {
|
||||
const cipher = new KruxCipher('passphrase', new TextEncoder().encode('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', new TextEncoder().encode('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('Payload too short for AES-GCM');
|
||||
await expect(cipher.decrypt(new Uint8Array(50), 99)).rejects.toThrow('Unsupported KEF version'); // Changed error message
|
||||
});
|
||||
|
||||
test('KruxCipher rejects short payload', async () => {
|
||||
const cipher = new KruxCipher('passphrase', new TextEncoder().encode('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 TextEncoder().encode('test payload');
|
||||
|
||||
// 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[6]).toBe(0);
|
||||
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);
|
||||
});
|
||||
|
||||
// New test case for user-provided KEF string
|
||||
|
||||
// New test case for user-provided KEF string - this one already uses base43Decode
|
||||
test('should correctly decrypt the user-provided KEF string', async () => {
|
||||
const kefData = "1334+HGXM$F8PPOIRNHX0.R*:SBMHK$X88LX$*/Y417R/6S1ZQOB2LHM-L+4T1YQVU:B*CKGXONP7:Y/R-B*:$R8FK";
|
||||
const passphrase = "aaa";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// src/lib/krux.ts
|
||||
// Krux KEF (Krux Encryption Format) implementation
|
||||
import * as pako from 'pako';
|
||||
import { base43Decode } from './base43';
|
||||
|
||||
// KEF version definitions, ported from kef.py
|
||||
import { base43Decode, base43Encode } from './base43';
|
||||
import { getWalletFingerprint } from './bip32';
|
||||
export const VERSIONS: Record<number, {
|
||||
name: string;
|
||||
compress: boolean;
|
||||
@@ -168,16 +167,27 @@ 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");
|
||||
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);
|
||||
// For encryption, we encode the string label to get the salt bytes
|
||||
const cipher = new KruxCipher(passphrase, new TextEncoder().encode(label), iterations);
|
||||
const payload = await cipher.encrypt(mnemonicBytes, version);
|
||||
const kef = wrap(label, version, iterations, payload);
|
||||
return { kefHex: bytesToHex(kef), label, version, iterations };
|
||||
const kefBase43 = base43Encode(kef);
|
||||
|
||||
console.log('🔐 KEF Debug:', { label, iterations, version, length: kef.length, base43: kefBase43.slice(0, 50) });
|
||||
|
||||
return { kefBase43, label, version, iterations };
|
||||
}
|
||||
|
||||
export function wrap(label: string, version: number, iterations: number, payload: Uint8Array): Uint8Array {
|
||||
|
||||
@@ -232,14 +232,11 @@ export async function encryptToSeed(params: EncryptionParams): Promise<Encryptio
|
||||
try {
|
||||
const result = await encryptToKrux({
|
||||
mnemonic: plaintextStr,
|
||||
passphrase,
|
||||
label: params.kruxLabel,
|
||||
iterations: params.kruxIterations,
|
||||
version: params.kruxVersion,
|
||||
passphrase: passphrase
|
||||
});
|
||||
|
||||
return {
|
||||
framed: result.kefHex,
|
||||
framed: result.kefBase43,
|
||||
label: result.label,
|
||||
version: result.version,
|
||||
iterations: result.iterations,
|
||||
|
||||
@@ -44,6 +44,7 @@ export type DecryptionParams = {
|
||||
export type EncryptionResult = {
|
||||
framed: string | Uint8Array;
|
||||
pgpBytes?: Uint8Array;
|
||||
kefBytes?: Uint8Array; // Added for Krux binary output
|
||||
recipientFingerprint?: string;
|
||||
label?: string;
|
||||
version?: number;
|
||||
|
||||
Reference in New Issue
Block a user