@@ -56,6 +56,36 @@ function moveDown(y: number, ydelta: number): number {
5656 return y + ydelta ;
5757}
5858
59+ /**
60+ * Prefixes one fragment of a split key with a part header so a QR scanner can reassemble the
61+ * fragments without relying on scan order.
62+ *
63+ * When a key box's payload exceeds {@link QRBinaryMaxLength}, {@link splitKeys} divides it into
64+ * multiple QR codes. Each fragment's QR payload is encoded as a 1-based part header followed by
65+ * the fragment:
66+ *
67+ * "<index>/<total>|<fragment>" e.g. "1/3|<chunk>", "2/3|<chunk>", "3/3|<chunk>"
68+ *
69+ * A single-fragment payload is returned unchanged (no header).
70+ *
71+ * Reassembly contract for a consumer (e.g. a future recovery/scan tool):
72+ * 1. Scan every QR code for a box.
73+ * 2. Split each payload on the FIRST "|": the left side is "<index>/<total>", the right side
74+ * is the fragment.
75+ * 3. Verify parts 1..total are all present (total is identical in every header).
76+ * 4. Concatenate the fragments in ascending index order to recover the full box payload.
77+ * 5. Parse/decrypt as usual (for a safe box: JSON.parse, then decrypt each root value).
78+ *
79+ * Notes:
80+ * - The "|" delimiter is safe: base64 ciphertext, JSON, and base58 xpubs never contain it.
81+ * - This header exists ONLY inside the QR image. The human-readable "Data:" text printed on
82+ * the card is the full, unheadered payload; the PDF-text parser reads that, so this header
83+ * does not affect PDF-based recovery.
84+ */
85+ function encodeQrCodePart ( fragment : string , index : number , total : number ) : string {
86+ return total > 1 ? `${ index + 1 } /${ total } |${ fragment } ` : fragment ;
87+ }
88+
5989// Draws QR codes down the left column, returning the index of the next QR still to draw (for
6090// continuation on a later page) and the y-offset just below the drawn QR column (so callers
6191// can place content, e.g. a note, under the QR codes).
@@ -118,6 +148,7 @@ export async function drawKeycard({
118148 walletLabel,
119149 curve,
120150 pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES ,
151+ useQrPartHeaders = false ,
121152} : IDrawKeyCard ) : Promise < jsPDF > {
122153 const jsPDFModule = await loadJSPDF ( ) ;
123154
@@ -206,8 +237,9 @@ export async function drawKeycard({
206237
207238 const qrImages : HTMLCanvasElement [ ] = [ ] ;
208239 const keys = splitKeys ( qr . data , QRBinaryMaxLength ) ;
209- for ( const key of keys ) {
210- qrImages . push ( await QRCode . toCanvas ( key , { errorCorrectionLevel : 'L' } ) ) ;
240+ for ( let i = 0 ; i < keys . length ; i ++ ) {
241+ const payload = useQrPartHeaders ? encodeQrCodePart ( keys [ i ] , i , keys . length ) : keys [ i ] ;
242+ qrImages . push ( await QRCode . toCanvas ( payload , { errorCorrectionLevel : 'L' } ) ) ;
211243 }
212244
213245 const isMultiPart = qr ?. data ?. length > QRBinaryMaxLength ;
0 commit comments