Skip to content

Commit 9648200

Browse files
s84krishclaude
andcommitted
chore(key-card): parse safe keycard box with io-ts + add QR part headers
- parseSafeKeycardBox: validate via an io-ts codec (JsonFromString piped into the roots codec) instead of manual JSON.parse + type checks - encode split-key QR fragments with a "<index>/<total>|" part header, opt-in via useQrPartHeaders (safe keycard only; wallet output unchanged) Ticket: WCN-1193 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a6a9712 commit 9648200

6 files changed

Lines changed: 79 additions & 25 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: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

modules/key-card/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,13 @@ export async function generateLightningKeycard(
5959
export async function generateSafeKeycard(params: GenerateQrDataBaseParams & GenerateSafeQrDataParams): Promise<void> {
6060
const questions = generateFaq(params.coin.fullName);
6161
const qrData = await generateSafeQrData(params);
62-
const keycard = await drawKeycard({ ...params, questions, qrData, pageBreakBeforeIndices: [1, 2] });
62+
const keycard = await drawKeycard({
63+
...params,
64+
questions,
65+
qrData,
66+
pageBreakBeforeIndices: [1, 2],
67+
useQrPartHeaders: true,
68+
});
6369
const label = params.walletLabel || params.coin.fullName;
6470
keycard.save(`BitGo Keycard for ${label}.pdf`);
6571
}

modules/key-card/src/parseKeycard.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { SafeKeycardRoots, SAFE_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 { SafeKeycardRoots } from './types';
26

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

20+
// A safe 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 SafeKeycardRootsCodec: t.Type<SafeKeycardRoots> = 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 SafeKeycardBoxFromString = JsonFromString.pipe(SafeKeycardRootsCodec);
31+
1632
/**
1733
* Parses a safe keycard box value — the JSON packed by `generateSafeQrData`, 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+
* safe password.
2138
*/
2239
export function parseSafeKeycardBox(data: string): SafeKeycardRoots {
23-
let parsed: unknown;
24-
try {
25-
parsed = JSON.parse(data);
26-
} catch {
27-
throw new Error('parseSafeKeycardBox: value is not valid JSON');
28-
}
29-
if (typeof parsed !== 'object' || parsed === null) {
30-
throw new Error('parseSafeKeycardBox: value is not an object');
31-
}
32-
const roots = parsed as Record<string, unknown>;
33-
for (const slot of SAFE_ROOT_ORDER) {
34-
if (typeof roots[slot] !== 'string') {
35-
throw new Error(`parseSafeKeycardBox: missing or invalid root ${slot}`);
36-
}
40+
const decoded = SafeKeycardBoxFromString.decode(data);
41+
if (isLeft(decoded)) {
42+
throw new Error(`parseSafeKeycardBox: ${PathReporter.report(decoded).join('; ')}`);
3743
}
38-
return parsed as SafeKeycardRoots;
44+
return decoded.right;
3945
}
4046

4147
const sectionHeaderRegex = /^([A-D])\s*[:.)-]\s*(.+?)\s*$/i;

modules/key-card/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export interface IDrawKeyCard {
9393
curve?: KeyCurve;
9494
// Box indices to start a new page before. Omit for the default wallet layout.
9595
pageBreakBeforeIndices?: number[];
96+
// When true, a split key's QR fragments are prefixed with a "<index>/<total>|" part header
97+
// so a scanner can reassemble them. Opt-in (used by the safe keycard); omit to leave QR
98+
// payloads as raw fragments, keeping the wallet keycard output unchanged.
99+
useQrPartHeaders?: boolean;
96100
}
97101

98102
export interface FAQ {

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,11 @@ describe('generateSafeQrData', function () {
190190
});
191191

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

0 commit comments

Comments
 (0)