diff --git a/modules/key-card/package.json b/modules/key-card/package.json index bd2101f64b..4867181554 100644 --- a/modules/key-card/package.json +++ b/modules/key-card/package.json @@ -36,6 +36,9 @@ "@bitgo/sdk-api": "^2.1.1", "@bitgo/sdk-core": "^38.2.0", "@bitgo/statics": "^58.56.0", + "fp-ts": "^2.16.2", + "io-ts": "npm:@bitgo-forks/io-ts@2.1.4", + "io-ts-types": "^0.5.19", "jspdf": ">=4.2.0", "pdfjs-dist": "^4.0.0", "qrcode": "^1.5.1" diff --git a/modules/key-card/src/drawKeycard.ts b/modules/key-card/src/drawKeycard.ts index 70b00f4e44..1e8ffa4b3f 100644 --- a/modules/key-card/src/drawKeycard.ts +++ b/modules/key-card/src/drawKeycard.ts @@ -58,6 +58,36 @@ function moveDown(y: number, ydelta: number): number { return y + ydelta; } +/** + * Prefixes one fragment of a split key with a part header so a QR scanner can reassemble the + * fragments without relying on scan order. + * + * When a key box's payload exceeds {@link QRBinaryMaxLength}, {@link splitKeys} divides it into + * multiple QR codes. Each fragment's QR payload is encoded as a 1-based part header followed by + * the fragment: + * + * "/|" e.g. "1/3|", "2/3|", "3/3|" + * + * A single-fragment payload is returned unchanged (no header). + * + * Reassembly contract for a consumer (e.g. a future recovery/scan tool): + * 1. Scan every QR code for a box. + * 2. Split each payload on the FIRST "|": the left side is "/", the right side + * is the fragment. + * 3. Verify parts 1..total are all present (total is identical in every header). + * 4. Concatenate the fragments in ascending index order to recover the full box payload. + * 5. Parse/decrypt as usual (for a safe box: JSON.parse, then decrypt each root value). + * + * Notes: + * - The "|" delimiter is safe: base64 ciphertext, JSON, and base58 xpubs never contain it. + * - This header exists ONLY inside the QR image. The human-readable "Data:" text printed on + * the card is the full, unheadered payload; the PDF-text parser reads that, so this header + * does not affect PDF-based recovery. + */ +function encodeQrCodePart(fragment: string, index: number, total: number): string { + return total > 1 ? `${index + 1}/${total}|${fragment}` : fragment; +} + // Draws QR codes down the left column, returning the index of the next QR still to draw (for // continuation on a later page) and the y-offset just below the drawn QR column (so callers // can place content, e.g. a note, under the QR codes). @@ -124,6 +154,7 @@ export async function drawKeycard({ walletLabel, curve, pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES, + useQrPartHeaders = false, }: IDrawKeyCard): Promise { const jsPDFModule = await loadJSPDF(); @@ -218,11 +249,12 @@ export async function drawKeycard({ const qrImages: (HTMLCanvasElement | string)[] = []; const keys = splitKeys(qr.data, QRBinaryMaxLength); - for (const key of keys) { + for (let i = 0; i < keys.length; i++) { + const payload = useQrPartHeaders ? encodeQrCodePart(keys[i], i, keys.length) : keys[i]; if (isNode) { - qrImages.push(await QRCode.toDataURL(key, { errorCorrectionLevel: 'L' })); + qrImages.push(await QRCode.toDataURL(payload, { errorCorrectionLevel: 'L' })); } else { - qrImages.push(await QRCode.toCanvas(key, { errorCorrectionLevel: 'L' })); + qrImages.push(await QRCode.toCanvas(payload, { errorCorrectionLevel: 'L' })); } } diff --git a/modules/key-card/src/index.ts b/modules/key-card/src/index.ts index c9e908cded..f8a47e7a2e 100644 --- a/modules/key-card/src/index.ts +++ b/modules/key-card/src/index.ts @@ -60,7 +60,13 @@ export async function generateLightningKeycard( export async function generateSafeKeycard(params: GenerateQrDataBaseParams & GenerateSafeQrDataParams): Promise { const questions = generateFaq(params.coin.fullName); const qrData = await generateSafeQrData(params); - const keycard = await drawKeycard({ ...params, questions, qrData, pageBreakBeforeIndices: [1, 2] }); + const keycard = await drawKeycard({ + ...params, + questions, + qrData, + pageBreakBeforeIndices: [1, 2], + useQrPartHeaders: true, + }); const label = params.walletLabel || params.coin.fullName; keycard.save(`BitGo Keycard for ${label}.pdf`); } diff --git a/modules/key-card/src/parseKeycard.ts b/modules/key-card/src/parseKeycard.ts index df2a9ad416..0b90792e40 100644 --- a/modules/key-card/src/parseKeycard.ts +++ b/modules/key-card/src/parseKeycard.ts @@ -1,4 +1,8 @@ -import { SafeKeycardRoots, SAFE_ROOT_ORDER } from './types'; +import * as t from 'io-ts'; +import { isLeft } from 'fp-ts/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { JsonFromString } from 'io-ts-types'; +import { SafeKeycardRoots } from './types'; export type PDFTextNode = { text: string; @@ -13,29 +17,31 @@ export type KeycardEntry = { value: string; }; +// A safe keycard box is a JSON object mapping each rootKeyType to a string (per-root +// ciphertext for A/B, public key for C). +const SafeKeycardRootsCodec: t.Type = t.type({ + secp256k1Multisig: t.string, + ecdsaMpc: t.string, + eddsaMpc: t.string, + ed25519Multisig: t.string, +}); + +// Decodes a box's JSON string straight into the validated roots record. +const SafeKeycardBoxFromString = JsonFromString.pipe(SafeKeycardRootsCodec); + /** * Parses a safe keycard box value — the JSON packed by `generateSafeQrData`, e.g. - * `{"secp256k1Multisig":"…","ecdsaMpc":"…",…}` — into its four roots. Validates that all four - * roots are present. Recovery tooling calls this on the A/B/C box value returned by - * {@link parseKeycardFromLines}, then decrypts each root value with the wallet password. + * `{"secp256k1Multisig":"…","ecdsaMpc":"…",…}` — into its four roots. Throws if the value is + * not valid JSON or any root is missing/non-string. Recovery tooling calls this on the A/B/C + * box value returned by {@link parseKeycardFromLines}, then decrypts each root value with the + * safe password. */ export function parseSafeKeycardBox(data: string): SafeKeycardRoots { - let parsed: unknown; - try { - parsed = JSON.parse(data); - } catch { - throw new Error('parseSafeKeycardBox: value is not valid JSON'); - } - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('parseSafeKeycardBox: value is not an object'); - } - const roots = parsed as Record; - for (const slot of SAFE_ROOT_ORDER) { - if (typeof roots[slot] !== 'string') { - throw new Error(`parseSafeKeycardBox: missing or invalid root ${slot}`); - } + const decoded = SafeKeycardBoxFromString.decode(data); + if (isLeft(decoded)) { + throw new Error(`parseSafeKeycardBox: ${PathReporter.report(decoded).join('; ')}`); } - return parsed as SafeKeycardRoots; + return decoded.right; } const sectionHeaderRegex = /^([A-D])\s*[:.)-]\s*(.+?)\s*$/i; diff --git a/modules/key-card/src/types.ts b/modules/key-card/src/types.ts index 544a19780e..385d3f6e40 100644 --- a/modules/key-card/src/types.ts +++ b/modules/key-card/src/types.ts @@ -96,6 +96,10 @@ export interface IDrawKeyCard { curve?: KeyCurve; // Box indices to start a new page before. Omit for the default wallet layout. pageBreakBeforeIndices?: number[]; + // When true, a split key's QR fragments are prefixed with a "/|" part header + // so a scanner can reassemble them. Opt-in (used by the safe keycard); omit to leave QR + // payloads as raw fragments, keeping the wallet keycard output unchanged. + useQrPartHeaders?: boolean; } export interface FAQ { diff --git a/modules/key-card/test/unit/safeQrData.ts b/modules/key-card/test/unit/safeQrData.ts index 3d124060cd..def736d15e 100644 --- a/modules/key-card/test/unit/safeQrData.ts +++ b/modules/key-card/test/unit/safeQrData.ts @@ -190,8 +190,11 @@ describe('generateSafeQrData', function () { }); it('parseSafeKeycardBox rejects malformed / incomplete box data', function () { - assert.throws(() => parseSafeKeycardBox('not json'), /not valid JSON/); - assert.throws(() => parseSafeKeycardBox('"a string"'), /not an object/); - assert.throws(() => parseSafeKeycardBox('{"secp256k1Multisig":"x"}'), /missing or invalid root/); + assert.throws(() => parseSafeKeycardBox('not json'), /parseSafeKeycardBox/); // invalid JSON + assert.throws(() => parseSafeKeycardBox('"a string"'), /parseSafeKeycardBox/); // not an object + assert.throws(() => parseSafeKeycardBox('{"secp256k1Multisig":"x"}'), /parseSafeKeycardBox/); // missing roots + // A well-formed box with all four roots decodes successfully. + const ok = parseSafeKeycardBox('{"secp256k1Multisig":"a","ecdsaMpc":"b","eddsaMpc":"c","ed25519Multisig":"d"}'); + ok.ecdsaMpc.should.equal('b'); }); });