mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
**🔧 Critical Fixes for Krux Hardware Wallet Compatibility** ### Base43 Encoding (Leading Zero Preservation) - Fix base43Decode to preserve leading zero bytes - Add proper boundary handling for empty strings and all-zero inputs - Match Krux Python implementation exactly - Prevents decryption failures with Krux encrypted data ### Krux KEF (Krux Encryption Format) - Fix iterations scaling: store value/10000 when divisible by 10000 - Add label length validation (max 252 chars) - Correct error validation order in decryptFromKrux - Fix boundary case: iterations = 10000 exactly ### SeedBlend Crypto Compatibility - Update getCrypto() to work in test environment - Remove import.meta.env.SSR check for better Node.js/Bun compatibility **Test Results:** - ✅ All 60 tests passing - ✅ 100% Krux compatibility verified - ✅ Real-world test vectors validated **Breaking Changes:** None - pure bug fixes for edge cases
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
/**
|
|
* @file seedqr.ts
|
|
* @summary Implements encoding and decoding for Seedsigner's SeedQR format.
|
|
* @description This module provides functions to convert BIP39 mnemonics to and from the
|
|
* SeedQR format, supporting both the Standard (numeric) and Compact (hex) variations.
|
|
* The logic is adapted from the official Seedsigner specification and test vectors.
|
|
*/
|
|
|
|
import { BIP39_WORDLIST, WORD_INDEX, mnemonicToEntropy, entropyToMnemonic } from './seedblend';
|
|
|
|
// Helper to convert a hex string to a Uint8Array in a browser-compatible way.
|
|
function hexToUint8Array(hex: string): Uint8Array {
|
|
if (hex.length % 2 !== 0) {
|
|
throw new Error('Hex string must have an even number of characters');
|
|
}
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/**
|
|
* Decodes a Standard SeedQR (numeric digit stream) into a mnemonic phrase.
|
|
* @param digitStream A string containing 4-digit numbers representing BIP39 word indices.
|
|
* @returns The decoded BIP39 mnemonic.
|
|
*/
|
|
function decodeStandardSeedQR(digitStream: string): string {
|
|
if (digitStream.length % 4 !== 0) {
|
|
throw new Error('Invalid Standard SeedQR: Length must be a multiple of 4.');
|
|
}
|
|
|
|
const wordIndices: number[] = [];
|
|
for (let i = 0; i < digitStream.length; i += 4) {
|
|
const indexStr = digitStream.slice(i, i + 4);
|
|
const index = parseInt(indexStr, 10);
|
|
if (isNaN(index) || index >= 2048) {
|
|
throw new Error(`Invalid word index in SeedQR: ${indexStr}`);
|
|
}
|
|
wordIndices.push(index);
|
|
}
|
|
|
|
if (wordIndices.length !== 12 && wordIndices.length !== 24) {
|
|
throw new Error(`Invalid word count from SeedQR: ${wordIndices.length}. Must be 12 or 24.`);
|
|
}
|
|
|
|
const mnemonicWords = wordIndices.map(index => BIP39_WORDLIST[index]);
|
|
return mnemonicWords.join(' ');
|
|
}
|
|
|
|
/**
|
|
* Decodes a Compact SeedQR (hex-encoded entropy) into a mnemonic phrase.
|
|
* @param hexEntropy The hex-encoded entropy string.
|
|
* @returns A promise that resolves to the decoded BIP39 mnemonic.
|
|
*/
|
|
async function decodeCompactSeedQR(hexEntropy: string): Promise<string> {
|
|
const entropy = hexToUint8Array(hexEntropy);
|
|
if (entropy.length !== 16 && entropy.length !== 32) {
|
|
throw new Error(`Invalid entropy length for Compact SeedQR: ${entropy.length}. Must be 16 or 32 bytes.`);
|
|
}
|
|
return entropyToMnemonic(entropy);
|
|
}
|
|
|
|
/**
|
|
* A unified decoder that automatically detects and parses a SeedQR string.
|
|
* @param qrData The raw data from the QR code.
|
|
* @returns A promise that resolves to the decoded BIP39 mnemonic.
|
|
*/
|
|
export async function decodeSeedQR(qrData: string): Promise<string> {
|
|
const trimmed = qrData.trim();
|
|
// Standard SeedQR is a string of only digits.
|
|
if (/^\d+$/.test(trimmed)) {
|
|
return decodeStandardSeedQR(trimmed);
|
|
}
|
|
// Compact SeedQR is a hex string.
|
|
if (/^[0-9a-fA-F]+$/.test(trimmed)) {
|
|
return decodeCompactSeedQR(trimmed);
|
|
}
|
|
throw new Error('Unsupported or invalid SeedQR format.');
|
|
}
|
|
|
|
/**
|
|
* Encodes a mnemonic into the Standard SeedQR format (numeric digit stream).
|
|
* @param mnemonic The BIP39 mnemonic string.
|
|
* @returns A promise that resolves to the Standard SeedQR string.
|
|
*/
|
|
export async function encodeStandardSeedQR(mnemonic: string): Promise<string> {
|
|
const words = mnemonic.trim().toLowerCase().split(/\s+/);
|
|
if (words.length !== 12 && words.length !== 24) {
|
|
throw new Error("Mnemonic must be 12 or 24 words to generate a SeedQR.");
|
|
}
|
|
|
|
const digitStream = words.map(word => {
|
|
const index = WORD_INDEX.get(word);
|
|
if (index === undefined) {
|
|
throw new Error(`Invalid word in mnemonic: ${word}`);
|
|
}
|
|
return index.toString().padStart(4, '0');
|
|
}).join('');
|
|
|
|
return digitStream;
|
|
}
|
|
|
|
/**
|
|
* Encodes a mnemonic into the Compact SeedQR format (raw entropy bytes).
|
|
* @param mnemonic The BIP39 mnemonic string.
|
|
* @returns A promise that resolves to the Compact SeedQR entropy as a Uint8Array.
|
|
*/
|
|
export async function encodeCompactSeedQREntropy(mnemonic: string): Promise<Uint8Array> {
|
|
return await mnemonicToEntropy(mnemonic);
|
|
}
|