Skip to content

Commit 4cacf1c

Browse files
committed
chore: use io-ts to parse vault keycard box + update keycard formatting
Ticket: WCN-1193
1 parent 66df7c8 commit 4cacf1c

7 files changed

Lines changed: 137 additions & 54 deletions

File tree

modules/key-card/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
"@bitgo/sdk-api": "^2.1.0",
3737
"@bitgo/sdk-core": "^38.1.0",
3838
"@bitgo/statics": "^58.55.0",
39+
"fp-ts": "^2.16.2",
40+
"io-ts": "npm:@bitgo-forks/io-ts@2.1.4",
41+
"io-ts-types": "^0.5.19",
3942
"jspdf": ">=4.2.0",
4043
"pdfjs-dist": "^4.0.0",
4144
"qrcode": "^1.5.1"

modules/key-card/src/drawKeycard.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,34 +56,67 @@ 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 vault 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+
89+
// Draws QR codes down the left column, returning the index of the next QR still to draw
90+
// (for continuation on a later page) and the y-offset just below the drawn QR column (so
91+
// callers can place content, e.g. a note, under the QR codes).
5992
function drawOnePageOfQrCodes(
6093
qrImages: HTMLCanvasElement[],
6194
doc: jsPDF,
6295
y: number,
6396
qrSize: number,
64-
startIndex
65-
): number {
97+
startIndex: number
98+
): { nextIndex: number; endY: number } {
6699
doc.setFont('helvetica');
67100
let qrIndex: number = startIndex;
68101
for (; qrIndex < qrImages.length; qrIndex++) {
69102
const image = qrImages[qrIndex];
70103
const textBuffer = 15;
71104
if (y + qrSize + textBuffer >= doc.internal.pageSize.getHeight()) {
72-
return qrIndex;
105+
return { nextIndex: qrIndex, endY: y };
73106
}
74107

75108
doc.addImage(image, left(0), y, qrSize, qrSize);
76109

77110
if (qrImages.length === 1) {
78-
return qrIndex + 1;
111+
return { nextIndex: qrIndex + 1, endY: y + qrSize };
79112
}
80113

81114
y = moveDown(y, qrSize + textBuffer);
82115
doc.setFontSize(font.body).setTextColor(color.black);
83116
doc.text('Part ' + (qrIndex + 1).toString(), left(0), y);
84117
y = moveDown(y, 20);
85118
}
86-
return qrIndex + 1;
119+
return { nextIndex: qrIndex, endY: y };
87120
}
88121

89122
function computeKeyCardImageDimensions(keyCardImage: HTMLImageElement) {
@@ -115,6 +148,7 @@ export async function drawKeycard({
115148
walletLabel,
116149
curve,
117150
pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES,
151+
useQrPartHeaders = false,
118152
}: IDrawKeyCard): Promise<jsPDF> {
119153
const jsPDFModule = await loadJSPDF();
120154

@@ -203,11 +237,26 @@ export async function drawKeycard({
203237

204238
const qrImages: HTMLCanvasElement[] = [];
205239
const keys = splitKeys(qr.data, QRBinaryMaxLength);
206-
for (const key of keys) {
207-
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' }));
208243
}
209244

210-
let nextQrIndex = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, 0);
245+
const isMultiPart = qr?.data?.length > QRBinaryMaxLength;
246+
const { nextIndex, endY: qrColumnEndY } = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, 0);
247+
let nextQrIndex = nextIndex;
248+
249+
// For a split key, place the "put all Parts together" note directly under the QR codes.
250+
// Wrap it to the QR column width so it stays in the left column and never overlaps the
251+
// Data payload rendered on the right.
252+
let qrColumnBottom = qrColumnEndY;
253+
if (isMultiPart) {
254+
doc.setFontSize(font.body - 2).setTextColor(color.darkgray);
255+
const noteLines = doc.splitTextToSize('Note: you will need to put all Parts together for the full key', qrSize);
256+
const noteY = moveDown(qrColumnEndY, 15);
257+
doc.text(noteLines, left(0), noteY);
258+
qrColumnBottom = noteY + noteLines.length * doc.getLineHeight();
259+
}
211260

212261
doc.setFontSize(font.subheader).setTextColor(color.black);
213262
y = moveDown(y, 10);
@@ -220,11 +269,6 @@ export async function drawKeycard({
220269
doc.text(qr.description, textLeft, y);
221270
textHeight += doc.getLineHeight();
222271
doc.setFontSize(font.body - 2);
223-
if (qr?.data?.length > QRBinaryMaxLength) {
224-
y = moveDown(y, 30);
225-
textHeight += 30;
226-
doc.text('Note: you will need to put all Parts together for the full key', textLeft, y);
227-
}
228272
y = moveDown(y, 30);
229273
textHeight += 30;
230274
doc.text('Data:', textLeft, y);
@@ -242,7 +286,11 @@ export async function drawKeycard({
242286
textHeight = 0;
243287
y = 30;
244288
topY = y;
245-
nextQrIndex = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, nextQrIndex);
289+
// Redraw remaining QRs on the new page and update the column bottom to THIS page, so
290+
// the rowHeight math below stays same-page (avoids a stale page-1 coordinate).
291+
const continued = drawOnePageOfQrCodes(qrImages, doc, y, qrSize, nextQrIndex);
292+
nextQrIndex = continued.nextIndex;
293+
qrColumnBottom = continued.endY;
246294
doc.setFont('courier').setFontSize(9).setTextColor(color.black);
247295
}
248296
doc.text(lines[line], textLeft, y);
@@ -273,9 +321,10 @@ export async function drawKeycard({
273321
}
274322

275323
doc.setFont('helvetica');
276-
// Move down the size of the QR code minus accumulated height on the right side, plus margin
277-
// if we have a key that spans multiple pages, then exclude QR code size
278-
const rowHeight = Math.max(qr.data.length > QRBinaryMaxLength ? qrSize + 20 : qrSize, textHeight);
324+
// Move down past the taller of the two columns: the right-side text (textHeight) or the
325+
// left-side QR column (plus the note printed under it for split keys), then add a margin.
326+
const qrColumnHeight = isMultiPart ? qrColumnBottom - topY : qrSize;
327+
const rowHeight = Math.max(qrColumnHeight, textHeight);
279328
const marginBottom = 15;
280329
y = moveDown(y, rowHeight - (y - topY) + marginBottom);
281330
}

modules/key-card/src/generateQrData.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
GenerateLightningQrDataParams,
77
GenerateQrDataParams,
88
GenerateVaultQrDataParams,
9+
KeycardEntity,
910
MasterPublicKeyQrDataEntry,
1011
QrData,
1112
QrDataEntry,
@@ -135,13 +136,15 @@ function generateUserMasterPublicKeyQRData(publicKey: string): MasterPublicKeyQr
135136
async function generatePasscodeQrData(
136137
passphrase: string,
137138
passcodeEncryptionCode: string,
138-
encryptionVersion?: 1 | 2
139+
encryptionVersion?: 1 | 2,
140+
entityNoun: KeycardEntity = 'wallet'
139141
): Promise<QrDataEntry> {
140-
const encryptedWalletPasscode = await encrypt(passcodeEncryptionCode, passphrase, { encryptionVersion });
142+
const encryptedPasscode = await encrypt(passcodeEncryptionCode, passphrase, { encryptionVersion });
143+
const titleNoun = entityNoun === 'vault' ? 'Vault' : 'Wallet';
141144
return {
142-
title: 'D: Encrypted Wallet Password',
143-
description: 'This is the wallet password, encrypted client-side with a key held by BitGo.',
144-
data: encryptedWalletPasscode,
145+
title: `D: Encrypted ${titleNoun} Password`,
146+
description: `This is the ${entityNoun} password, encrypted client-side with a key held by BitGo.`,
147+
data: encryptedPasscode,
145148
};
146149
}
147150

@@ -263,12 +266,12 @@ export async function generateVaultQrData(params: GenerateVaultQrDataParams): Pr
263266
const qrData: QrData = {
264267
user: {
265268
title: 'A: User Key',
266-
description: 'Your 4 root private keys, encrypted with your wallet password.',
269+
description: 'Your 4 root private keys, encrypted with your vault password.',
267270
data: buildVaultBoxData(roots, (root, slot) => selectRootPrivateKey(root.userKeychain, slot, 'user')),
268271
},
269272
backup: {
270273
title: 'B: Backup Key',
271-
description: 'Your 4 root backup keys, encrypted with your wallet password.',
274+
description: 'Your 4 root backup keys, encrypted with your vault password.',
272275
data: buildVaultBoxData(roots, (root, slot) => selectRootPrivateKey(root.backupKeychain, slot, 'backup')),
273276
},
274277
bitgo: {
@@ -282,7 +285,8 @@ export async function generateVaultQrData(params: GenerateVaultQrDataParams): Pr
282285
qrData.passcode = await generatePasscodeQrData(
283286
params.passphrase,
284287
params.passcodeEncryptionCode,
285-
params.encryptionVersion
288+
params.encryptionVersion,
289+
'vault'
286290
);
287291
}
288292

modules/key-card/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,13 @@ export async function generateVaultKeycard(
6161
): Promise<void> {
6262
const questions = generateFaq(params.coin.fullName);
6363
const qrData = await generateVaultQrData(params);
64-
const keycard = await drawKeycard({ ...params, questions, qrData, pageBreakBeforeIndices: [1, 2] });
64+
const keycard = await drawKeycard({
65+
...params,
66+
questions,
67+
qrData,
68+
pageBreakBeforeIndices: [1, 2],
69+
useQrPartHeaders: true,
70+
});
6571
const label = params.walletLabel || params.coin.fullName;
6672
keycard.save(`BitGo Keycard for ${label}.pdf`);
6773
}

modules/key-card/src/parseKeycard.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { VaultKeycardRoots, VAULT_ROOT_ORDER } from './types';
1+
import * as t from 'io-ts';
2+
import { isLeft } from 'fp-ts/Either';
3+
import { PathReporter } from 'io-ts/lib/PathReporter';
4+
import { JsonFromString } from 'io-ts-types';
5+
import { VaultKeycardRoots } from './types';
26

37
export type PDFTextNode = {
48
text: string;
@@ -13,29 +17,31 @@ export type KeycardEntry = {
1317
value: string;
1418
};
1519

20+
// A vault keycard box is a JSON object mapping each rootKeyType to a string (per-root
21+
// ciphertext for A/B, public key for C).
22+
const VaultKeycardRootsCodec: t.Type<VaultKeycardRoots> = t.type({
23+
secp256k1Multisig: t.string,
24+
ecdsaMpc: t.string,
25+
eddsaMpc: t.string,
26+
ed25519Multisig: t.string,
27+
});
28+
29+
// Decodes a box's JSON string straight into the validated roots record.
30+
const VaultKeycardBoxFromString = JsonFromString.pipe(VaultKeycardRootsCodec);
31+
1632
/**
1733
* Parses a vault keycard box value — the JSON packed by `generateVaultQrData`, e.g.
18-
* `{"secp256k1Multisig":"…","ecdsaMpc":"…",…}` — into its four roots. Validates that all four
19-
* roots are present. Recovery tooling calls this on the A/B/C box value returned by
20-
* {@link parseKeycardFromLines}, then decrypts each root value with the wallet password.
34+
* `{"secp256k1Multisig":"…","ecdsaMpc":"…",…}` — into its four roots. Throws if the value is
35+
* not valid JSON or any root is missing/non-string. Recovery tooling calls this on the A/B/C
36+
* box value returned by {@link parseKeycardFromLines}, then decrypts each root value with the
37+
* vault password.
2138
*/
2239
export function parseVaultKeycardBox(data: string): VaultKeycardRoots {
23-
let parsed: unknown;
24-
try {
25-
parsed = JSON.parse(data);
26-
} catch {
27-
throw new Error('parseVaultKeycardBox: value is not valid JSON');
28-
}
29-
if (typeof parsed !== 'object' || parsed === null) {
30-
throw new Error('parseVaultKeycardBox: value is not an object');
31-
}
32-
const roots = parsed as Record<string, unknown>;
33-
for (const slot of VAULT_ROOT_ORDER) {
34-
if (typeof roots[slot] !== 'string') {
35-
throw new Error(`parseVaultKeycardBox: missing or invalid root ${slot}`);
36-
}
40+
const decoded = VaultKeycardBoxFromString.decode(data);
41+
if (isLeft(decoded)) {
42+
throw new Error(`parseVaultKeycardBox: ${PathReporter.report(decoded).join('; ')}`);
3743
}
38-
return parsed as VaultKeycardRoots;
44+
return decoded.right;
3945
}
4046

4147
const sectionHeaderRegex = /^([A-D])\s*[:.)-]\s*(.+?)\s*$/i;
@@ -73,7 +79,9 @@ function countChar(input: string, char: string): number {
7379
}
7480

7581
function isEncryptedWalletPasswordSectionTitle(title: string): boolean {
76-
return title.toLowerCase().includes('encrypted wallet password');
82+
const normalized = title.toLowerCase();
83+
// Box D is titled "Encrypted Wallet Password" (wallet) or "Encrypted Vault Password" (vault).
84+
return normalized.includes('encrypted wallet password') || normalized.includes('encrypted vault password');
7785
}
7886

7987
/**

modules/key-card/src/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ export const VAULT_ROOT_ORDER: VaultRootKeyType[] = ['secp256k1Multisig', 'ecdsa
7676
*/
7777
export type VaultKeycardRoots = Record<VaultRootKeyType, string>;
7878

79+
/** The product a keycard belongs to, used for user-facing wording (e.g. Box D copy). */
80+
export type KeycardEntity = 'wallet' | 'vault';
81+
7982
export interface GenerateVaultQrDataParams extends GenerateQrDataCoinParams {
8083
// The four root triplets (user/backup/bitgo keychains), keyed by rootKeyType.
8184
roots: Record<VaultRootKeyType, KeychainsTriplet>;
@@ -91,8 +94,12 @@ export interface IDrawKeyCard {
9194
questions: FAQ[];
9295
walletLabel?: string;
9396
curve?: KeyCurve;
94-
// Box indices to start a new page before. Omit for the default wallet layout.
97+
// Insert page breaks at the given box indices. If not provided, defaults to single break before the 3rd box (default layout).
9598
pageBreakBeforeIndices?: number[];
99+
// When true, a split key's QR fragments are prefixed with a "<index>/<total>|" part header
100+
// so a scanner can reassemble them. Opt-in (used by the vault keycard); omit to leave QR
101+
// payloads as raw fragments, keeping the wallet keycard output unchanged.
102+
useQrPartHeaders?: boolean;
96103
}
97104

98105
export interface FAQ {

modules/key-card/test/unit/vaultQrData.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,17 +131,20 @@ describe('generateVaultQrData', function () {
131131
userBox['ed25519Multisig'].should.equal(roots.ed25519Multisig.userKeychain.encryptedPrv);
132132
});
133133

134-
it('includes box D when passphrase + passcodeEncryptionCode are provided', async function () {
134+
it('includes box D with vault wording when passphrase + passcodeEncryptionCode are provided', async function () {
135135
const { roots } = await buildRoots();
136136
const qrData = await generateVaultQrData({
137137
coin: coins.get('btc'),
138138
roots,
139-
passphrase: 'wallet-pw',
139+
passphrase: 'vault-pw',
140140
passcodeEncryptionCode: '123456',
141141
});
142142
assert.ok(qrData.passcode);
143-
qrData.passcode.title.should.equal('D: Encrypted Wallet Password');
144-
(await decrypt('123456', qrData.passcode.data)).should.equal('wallet-pw');
143+
qrData.passcode.title.should.equal('D: Encrypted Vault Password');
144+
qrData.passcode.description.should.equal(
145+
'This is the vault password, encrypted client-side with a key held by BitGo.'
146+
);
147+
(await decrypt('123456', qrData.passcode.data)).should.equal('vault-pw');
145148
});
146149

147150
it('throws when a user root is missing private key material', async function () {
@@ -187,8 +190,11 @@ describe('generateVaultQrData', function () {
187190
});
188191

189192
it('parseVaultKeycardBox rejects malformed / incomplete box data', function () {
190-
assert.throws(() => parseVaultKeycardBox('not json'), /not valid JSON/);
191-
assert.throws(() => parseVaultKeycardBox('"a string"'), /not an object/);
192-
assert.throws(() => parseVaultKeycardBox('{"secp256k1Multisig":"x"}'), /missing or invalid root/);
193+
assert.throws(() => parseVaultKeycardBox('not json'), /parseVaultKeycardBox/); // invalid JSON
194+
assert.throws(() => parseVaultKeycardBox('"a string"'), /parseVaultKeycardBox/); // not an object
195+
assert.throws(() => parseVaultKeycardBox('{"secp256k1Multisig":"x"}'), /parseVaultKeycardBox/); // missing roots
196+
// A well-formed box with all four roots decodes successfully.
197+
const ok = parseVaultKeycardBox('{"secp256k1Multisig":"a","ecdsaMpc":"b","eddsaMpc":"c","ed25519Multisig":"d"}');
198+
ok.ecdsaMpc.should.equal('b');
193199
});
194200
});

0 commit comments

Comments
 (0)