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.0.0",
"@bitgo/sdk-core": "^38.0.0",
"@bitgo/statics": "^58.54.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
83 changes: 66 additions & 17 deletions modules/key-card/src/drawKeycard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,34 +56,67 @@ 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 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;

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).
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);
doc.setFontSize(font.body).setTextColor(color.black);
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) {
Expand Down Expand Up @@ -115,6 +148,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 @@ -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;
Comment thread
s84krish marked this conversation as resolved.
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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Comment thread
s84krish marked this conversation as resolved.
const qrColumnHeight = isMultiPart ? qrColumnBottom - topY : qrSize;
const rowHeight = Math.max(qrColumnHeight, textHeight);
const marginBottom = 15;
y = moveDown(y, rowHeight - (y - topY) + marginBottom);
}
Expand Down
20 changes: 12 additions & 8 deletions modules/key-card/src/generateQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
GenerateLightningQrDataParams,
GenerateQrDataParams,
GenerateVaultQrDataParams,
KeycardEntity,
MasterPublicKeyQrDataEntry,
QrData,
QrDataEntry,
Expand Down Expand Up @@ -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<QrDataEntry> {
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,
};
}

Expand Down Expand Up @@ -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: {
Expand All @@ -282,7 +285,8 @@ export async function generateVaultQrData(params: GenerateVaultQrDataParams): Pr
qrData.passcode = await generatePasscodeQrData(
params.passphrase,
params.passcodeEncryptionCode,
params.encryptionVersion
params.encryptionVersion,
'vault'
);
}

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 @@ -61,7 +61,13 @@ export async function generateVaultKeycard(
): Promise<void> {
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`);
}
48 changes: 28 additions & 20 deletions modules/key-card/src/parseKeycard.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<VaultKeycardRoots> = 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<string, unknown>;
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;
Expand Down Expand Up @@ -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');
}

/**
Expand Down
9 changes: 8 additions & 1 deletion modules/key-card/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export const VAULT_ROOT_ORDER: VaultRootKeyType[] = ['secp256k1Multisig', 'ecdsa
*/
export type VaultKeycardRoots = Record<VaultRootKeyType, string>;

/** 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<VaultRootKeyType, KeychainsTriplet>;
Expand All @@ -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 "<index>/<total>|" 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 {
Expand Down
20 changes: 13 additions & 7 deletions modules/key-card/test/unit/vaultQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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');
});
});
Loading