Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions modules/key-card/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 35 additions & 3 deletions modules/key-card/src/drawKeycard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
* "<index>/<total>|<fragment>" e.g. "1/3|<chunk>", "2/3|<chunk>", "3/3|<chunk>"
*
* 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 "<index>/<total>", 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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering what the best way to do this parts encoding would be - if it is even necessary.

}

// 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).
Expand Down Expand Up @@ -124,6 +154,7 @@ export async function drawKeycard({
walletLabel,
curve,
pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES,
useQrPartHeaders = false,
}: IDrawKeyCard): Promise<jsPDF> {
const jsPDFModule = await loadJSPDF();

Expand Down Expand Up @@ -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' }));
}
}

Expand Down
8 changes: 7 additions & 1 deletion modules/key-card/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ export async function generateLightningKeycard(
export async function generateSafeKeycard(params: GenerateQrDataBaseParams & GenerateSafeQrDataParams): Promise<void> {
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`);
}
44 changes: 25 additions & 19 deletions modules/key-card/src/parseKeycard.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<SafeKeycardRoots> = 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<string, unknown>;
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;
Expand Down
4 changes: 4 additions & 0 deletions modules/key-card/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<index>/<total>|" 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 {
Expand Down
9 changes: 6 additions & 3 deletions modules/key-card/test/unit/safeQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading