diff --git a/modules/key-card/package.json b/modules/key-card/package.json index a3d3febcfd..5a11b66fe2 100644 --- a/modules/key-card/package.json +++ b/modules/key-card/package.json @@ -36,6 +36,9 @@ "@bitgo/sdk-api": "^2.1.0", "@bitgo/sdk-core": "^38.1.0", "@bitgo/statics": "^58.55.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 4fb7488cf7..ecc7fe28ab 100644 --- a/modules/key-card/src/drawKeycard.ts +++ b/modules/key-card/src/drawKeycard.ts @@ -56,26 +56,59 @@ 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 vault 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). function drawOnePageOfQrCodes( qrImages: HTMLCanvasElement[], doc: jsPDF, y: number, qrSize: number, - startIndex -): number { + startIndex: number +): { nextIndex: number; endY: number } { doc.setFont('helvetica'); let qrIndex: number = startIndex; for (; qrIndex < qrImages.length; qrIndex++) { const image = qrImages[qrIndex]; const textBuffer = 15; if (y + qrSize + textBuffer >= doc.internal.pageSize.getHeight()) { - return qrIndex; + return { nextIndex: qrIndex, endY: y }; } doc.addImage(image, left(0), y, qrSize, qrSize); if (qrImages.length === 1) { - return qrIndex + 1; + return { nextIndex: qrIndex + 1, endY: y + qrSize }; } y = moveDown(y, qrSize + textBuffer); @@ -83,7 +116,7 @@ function drawOnePageOfQrCodes( doc.text('Part ' + (qrIndex + 1).toString(), left(0), y); y = moveDown(y, 20); } - return qrIndex + 1; + return { nextIndex: qrIndex, endY: y }; } function computeKeyCardImageDimensions(keyCardImage: HTMLImageElement) { @@ -115,6 +148,7 @@ export async function drawKeycard({ walletLabel, curve, pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES, + useQrPartHeaders = false, }: IDrawKeyCard): Promise { const jsPDFModule = await loadJSPDF(); @@ -203,11 +237,26 @@ export async function drawKeycard({ const qrImages: HTMLCanvasElement[] = []; const keys = splitKeys(qr.data, QRBinaryMaxLength); - for (const key of keys) { - qrImages.push(await QRCode.toCanvas(key, { errorCorrectionLevel: 'L' })); + for (let i = 0; i < keys.length; i++) { + const payload = useQrPartHeaders ? encodeQrCodePart(keys[i], i, keys.length) : keys[i]; + qrImages.push(await QRCode.toCanvas(payload, { errorCorrectionLevel: 'L' })); } - let nextQrIndex = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, 0); + const isMultiPart = qr?.data?.length > QRBinaryMaxLength; + const { nextIndex, endY: qrColumnEndY } = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, 0); + let nextQrIndex = nextIndex; + + // For a split key, place the "put all Parts together" note directly under the QR codes. + // Wrap it to the QR column width so it stays in the left column and never overlaps the + // Data payload rendered on the right. + let qrColumnBottom = qrColumnEndY; + if (isMultiPart) { + doc.setFontSize(font.body - 2).setTextColor(color.darkgray); + const noteLines = doc.splitTextToSize('Note: you will need to put all Parts together for the full key', qrSize); + const noteY = moveDown(qrColumnEndY, 15); + doc.text(noteLines, left(0), noteY); + qrColumnBottom = noteY + noteLines.length * doc.getLineHeight(); + } doc.setFontSize(font.subheader).setTextColor(color.black); y = moveDown(y, 10); @@ -220,11 +269,6 @@ export async function drawKeycard({ doc.text(qr.description, textLeft, y); textHeight += doc.getLineHeight(); doc.setFontSize(font.body - 2); - if (qr?.data?.length > QRBinaryMaxLength) { - y = moveDown(y, 30); - textHeight += 30; - doc.text('Note: you will need to put all Parts together for the full key', textLeft, y); - } y = moveDown(y, 30); textHeight += 30; doc.text('Data:', textLeft, y); @@ -242,7 +286,11 @@ export async function drawKeycard({ textHeight = 0; y = 30; topY = y; - nextQrIndex = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, nextQrIndex); + // Redraw remaining QRs on the new page and update the column bottom to THIS page, so + // the rowHeight math below stays same-page (avoids a stale page-1 coordinate). + const continued = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, nextQrIndex); + nextQrIndex = continued.nextIndex; + qrColumnBottom = continued.endY; doc.setFont('courier').setFontSize(9).setTextColor(color.black); } doc.text(lines[line], textLeft, y); @@ -273,9 +321,10 @@ export async function drawKeycard({ } doc.setFont('helvetica'); - // Move down the size of the QR code minus accumulated height on the right side, plus margin - // if we have a key that spans multiple pages, then exclude QR code size - const rowHeight = Math.max(qr.data.length > QRBinaryMaxLength ? qrSize + 20 : qrSize, textHeight); + // Move down past the taller of the two columns: the right-side text (textHeight) or the + // left-side QR column (plus the note printed under it for split keys), then add a margin. + const qrColumnHeight = isMultiPart ? qrColumnBottom - topY : qrSize; + const rowHeight = Math.max(qrColumnHeight, textHeight); const marginBottom = 15; y = moveDown(y, rowHeight - (y - topY) + marginBottom); } diff --git a/modules/key-card/src/generateQrData.ts b/modules/key-card/src/generateQrData.ts index 5bf437770b..5e283feaf8 100644 --- a/modules/key-card/src/generateQrData.ts +++ b/modules/key-card/src/generateQrData.ts @@ -6,6 +6,7 @@ import { GenerateLightningQrDataParams, GenerateQrDataParams, GenerateVaultQrDataParams, + KeycardEntity, MasterPublicKeyQrDataEntry, QrData, QrDataEntry, @@ -135,13 +136,15 @@ function generateUserMasterPublicKeyQRData(publicKey: string): MasterPublicKeyQr async function generatePasscodeQrData( passphrase: string, passcodeEncryptionCode: string, - encryptionVersion?: 1 | 2 + encryptionVersion?: 1 | 2, + entityNoun: KeycardEntity = 'wallet' ): Promise { - const encryptedWalletPasscode = await encrypt(passcodeEncryptionCode, passphrase, { encryptionVersion }); + const encryptedPasscode = await encrypt(passcodeEncryptionCode, passphrase, { encryptionVersion }); + const titleNoun = entityNoun === 'vault' ? 'Vault' : 'Wallet'; return { - title: 'D: Encrypted Wallet Password', - description: 'This is the wallet password, encrypted client-side with a key held by BitGo.', - data: encryptedWalletPasscode, + title: `D: Encrypted ${titleNoun} Password`, + description: `This is the ${entityNoun} password, encrypted client-side with a key held by BitGo.`, + data: encryptedPasscode, }; } @@ -263,12 +266,12 @@ export async function generateVaultQrData(params: GenerateVaultQrDataParams): Pr const qrData: QrData = { user: { title: 'A: User Key', - description: 'Your 4 root private keys, encrypted with your wallet password.', + description: 'Your 4 root private keys, encrypted with your vault password.', data: buildVaultBoxData(roots, (root, slot) => selectRootPrivateKey(root.userKeychain, slot, 'user')), }, backup: { title: 'B: Backup Key', - description: 'Your 4 root backup keys, encrypted with your wallet password.', + description: 'Your 4 root backup keys, encrypted with your vault password.', data: buildVaultBoxData(roots, (root, slot) => selectRootPrivateKey(root.backupKeychain, slot, 'backup')), }, bitgo: { @@ -282,7 +285,8 @@ export async function generateVaultQrData(params: GenerateVaultQrDataParams): Pr qrData.passcode = await generatePasscodeQrData( params.passphrase, params.passcodeEncryptionCode, - params.encryptionVersion + params.encryptionVersion, + 'vault' ); } diff --git a/modules/key-card/src/index.ts b/modules/key-card/src/index.ts index 617a8c43b3..e0ca712355 100644 --- a/modules/key-card/src/index.ts +++ b/modules/key-card/src/index.ts @@ -61,7 +61,13 @@ export async function generateVaultKeycard( ): Promise { const questions = generateFaq(params.coin.fullName); const qrData = await generateVaultQrData(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 98a972acfb..b4adf3b462 100644 --- a/modules/key-card/src/parseKeycard.ts +++ b/modules/key-card/src/parseKeycard.ts @@ -1,4 +1,8 @@ -import { VaultKeycardRoots, VAULT_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 { VaultKeycardRoots } from './types'; export type PDFTextNode = { text: string; @@ -13,29 +17,31 @@ export type KeycardEntry = { value: string; }; +// A vault keycard box is a JSON object mapping each rootKeyType to a string (per-root +// ciphertext for A/B, public key for C). +const VaultKeycardRootsCodec: 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 VaultKeycardBoxFromString = JsonFromString.pipe(VaultKeycardRootsCodec); + /** * Parses a vault keycard box value — the JSON packed by `generateVaultQrData`, 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 + * vault password. */ export function parseVaultKeycardBox(data: string): VaultKeycardRoots { - let parsed: unknown; - try { - parsed = JSON.parse(data); - } catch { - throw new Error('parseVaultKeycardBox: value is not valid JSON'); - } - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('parseVaultKeycardBox: value is not an object'); - } - const roots = parsed as Record; - for (const slot of VAULT_ROOT_ORDER) { - if (typeof roots[slot] !== 'string') { - throw new Error(`parseVaultKeycardBox: missing or invalid root ${slot}`); - } + const decoded = VaultKeycardBoxFromString.decode(data); + if (isLeft(decoded)) { + throw new Error(`parseVaultKeycardBox: ${PathReporter.report(decoded).join('; ')}`); } - return parsed as VaultKeycardRoots; + return decoded.right; } const sectionHeaderRegex = /^([A-D])\s*[:.)-]\s*(.+?)\s*$/i; @@ -73,7 +79,9 @@ function countChar(input: string, char: string): number { } function isEncryptedWalletPasswordSectionTitle(title: string): boolean { - return title.toLowerCase().includes('encrypted wallet password'); + const normalized = title.toLowerCase(); + // Box D is titled "Encrypted Wallet Password" (wallet) or "Encrypted Vault Password" (vault). + return normalized.includes('encrypted wallet password') || normalized.includes('encrypted vault password'); } /** diff --git a/modules/key-card/src/types.ts b/modules/key-card/src/types.ts index 79d2092eee..641eb5df9f 100644 --- a/modules/key-card/src/types.ts +++ b/modules/key-card/src/types.ts @@ -76,6 +76,9 @@ export const VAULT_ROOT_ORDER: VaultRootKeyType[] = ['secp256k1Multisig', 'ecdsa */ export type VaultKeycardRoots = Record; +/** The product a keycard belongs to, used for user-facing wording (e.g. Box D copy). */ +export type KeycardEntity = 'wallet' | 'vault'; + export interface GenerateVaultQrDataParams extends GenerateQrDataCoinParams { // The four root triplets (user/backup/bitgo keychains), keyed by rootKeyType. roots: Record; @@ -91,8 +94,12 @@ export interface IDrawKeyCard { questions: FAQ[]; walletLabel?: string; curve?: KeyCurve; - // Box indices to start a new page before. Omit for the default wallet layout. + // Insert page breaks at the given box indices. If not provided, defaults to single break before the 3rd box (default 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 vault 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/vaultQrData.ts b/modules/key-card/test/unit/vaultQrData.ts index 8375f53457..5bc5000bc1 100644 --- a/modules/key-card/test/unit/vaultQrData.ts +++ b/modules/key-card/test/unit/vaultQrData.ts @@ -131,17 +131,20 @@ describe('generateVaultQrData', function () { userBox['ed25519Multisig'].should.equal(roots.ed25519Multisig.userKeychain.encryptedPrv); }); - it('includes box D when passphrase + passcodeEncryptionCode are provided', async function () { + it('includes box D with vault wording when passphrase + passcodeEncryptionCode are provided', async function () { const { roots } = await buildRoots(); const qrData = await generateVaultQrData({ coin: coins.get('btc'), roots, - passphrase: 'wallet-pw', + passphrase: 'vault-pw', passcodeEncryptionCode: '123456', }); assert.ok(qrData.passcode); - qrData.passcode.title.should.equal('D: Encrypted Wallet Password'); - (await decrypt('123456', qrData.passcode.data)).should.equal('wallet-pw'); + qrData.passcode.title.should.equal('D: Encrypted Vault Password'); + qrData.passcode.description.should.equal( + 'This is the vault password, encrypted client-side with a key held by BitGo.' + ); + (await decrypt('123456', qrData.passcode.data)).should.equal('vault-pw'); }); it('throws when a user root is missing private key material', async function () { @@ -187,8 +190,11 @@ describe('generateVaultQrData', function () { }); it('parseVaultKeycardBox rejects malformed / incomplete box data', function () { - assert.throws(() => parseVaultKeycardBox('not json'), /not valid JSON/); - assert.throws(() => parseVaultKeycardBox('"a string"'), /not an object/); - assert.throws(() => parseVaultKeycardBox('{"secp256k1Multisig":"x"}'), /missing or invalid root/); + assert.throws(() => parseVaultKeycardBox('not json'), /parseVaultKeycardBox/); // invalid JSON + assert.throws(() => parseVaultKeycardBox('"a string"'), /parseVaultKeycardBox/); // not an object + assert.throws(() => parseVaultKeycardBox('{"secp256k1Multisig":"x"}'), /parseVaultKeycardBox/); // missing roots + // A well-formed box with all four roots decodes successfully. + const ok = parseVaultKeycardBox('{"secp256k1Multisig":"a","ecdsaMpc":"b","eddsaMpc":"c","ed25519Multisig":"d"}'); + ok.ecdsaMpc.should.equal('b'); }); });