mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
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:
111
src/lib/seedqr.ts
Normal file
111
src/lib/seedqr.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user