@@ -58,6 +58,36 @@ function moveDown(y: number, ydelta: number): number {
5858 return y + ydelta ;
5959}
6060
61+ /**
62+ * Prefixes one fragment of a split key with a part header so a QR scanner can reassemble the
63+ * fragments without relying on scan order.
64+ *
65+ * When a key box's payload exceeds {@link QRBinaryMaxLength}, {@link splitKeys} divides it into
66+ * multiple QR codes. Each fragment's QR payload is encoded as a 1-based part header followed by
67+ * the fragment:
68+ *
69+ * "<index>/<total>|<fragment>" e.g. "1/3|<chunk>", "2/3|<chunk>", "3/3|<chunk>"
70+ *
71+ * A single-fragment payload is returned unchanged (no header).
72+ *
73+ * Reassembly contract for a consumer (e.g. a future recovery/scan tool):
74+ * 1. Scan every QR code for a box.
75+ * 2. Split each payload on the FIRST "|": the left side is "<index>/<total>", the right side
76+ * is the fragment.
77+ * 3. Verify parts 1..total are all present (total is identical in every header).
78+ * 4. Concatenate the fragments in ascending index order to recover the full box payload.
79+ * 5. Parse/decrypt as usual (for a safe box: JSON.parse, then decrypt each root value).
80+ *
81+ * Notes:
82+ * - The "|" delimiter is safe: base64 ciphertext, JSON, and base58 xpubs never contain it.
83+ * - This header exists ONLY inside the QR image. The human-readable "Data:" text printed on
84+ * the card is the full, unheadered payload; the PDF-text parser reads that, so this header
85+ * does not affect PDF-based recovery.
86+ */
87+ function encodeQrCodePart ( fragment : string , index : number , total : number ) : string {
88+ return total > 1 ? `${ index + 1 } /${ total } |${ fragment } ` : fragment ;
89+ }
90+
6191// Draws QR codes down the left column, returning the index of the next QR still to draw (for
6292// continuation on a later page) and the y-offset just below the drawn QR column (so callers
6393// can place content, e.g. a note, under the QR codes).
@@ -124,6 +154,7 @@ export async function drawKeycard({
124154 walletLabel,
125155 curve,
126156 pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES ,
157+ useQrPartHeaders = false ,
127158} : IDrawKeyCard ) : Promise < jsPDF > {
128159 const jsPDFModule = await loadJSPDF ( ) ;
129160
@@ -218,11 +249,12 @@ export async function drawKeycard({
218249
219250 const qrImages : ( HTMLCanvasElement | string ) [ ] = [ ] ;
220251 const keys = splitKeys ( qr . data , QRBinaryMaxLength ) ;
221- for ( const key of keys ) {
252+ for ( let i = 0 ; i < keys . length ; i ++ ) {
253+ const payload = useQrPartHeaders ? encodeQrCodePart ( keys [ i ] , i , keys . length ) : keys [ i ] ;
222254 if ( isNode ) {
223- qrImages . push ( await QRCode . toDataURL ( key , { errorCorrectionLevel : 'L' } ) ) ;
255+ qrImages . push ( await QRCode . toDataURL ( payload , { errorCorrectionLevel : 'L' } ) ) ;
224256 } else {
225- qrImages . push ( await QRCode . toCanvas ( key , { errorCorrectionLevel : 'L' } ) ) ;
257+ qrImages . push ( await QRCode . toCanvas ( payload , { errorCorrectionLevel : 'L' } ) ) ;
226258 }
227259 }
228260
0 commit comments