feat: fix CompactSeedQR binary QR code scanning with jsQR library

- Replace BarcodeDetector with jsQR for raw binary byte access
- BarcodeDetector forced UTF-8 decoding which corrupted binary data
- jsQR's binaryData property preserves raw bytes without text conversion
- Fix regex bug: use single backslash \x00 instead of \x00 for binary detection
- Add debug logging for scan data inspection
- QR generation already worked (Krux-compatible), only scanning was broken

Resolves binary QR code scanning for 12/24-word CompactSeedQR format.
Tested with Krux device - full bidirectional compatibility confirmed.
This commit is contained in:
LC mac
2026-02-07 04:22:56 +08:00
parent 49d73a7ae4
commit aa06c9ae27
39 changed files with 4664 additions and 777 deletions

111
src/lib/seedqr.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* @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);
}