Skip to content
Merged
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
49 changes: 33 additions & 16 deletions modules/key-card/src/drawKeycard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ enum KeyCurveName {
export const QRBinaryMaxLength = 1500;

// Default page-break layout: start a new page before the 3rd box (index 2), matching the
// historical wallet keycard. Callers (e.g. the vault keycard) may override with their own indices.
// historical wallet keycard. Callers (e.g. the safe keycard) may override with their own indices.
const DEFAULT_PAGE_BREAK_INDICES = [2];

const font = {
Expand All @@ -56,34 +56,37 @@ function moveDown(y: number, ydelta: number): number {
return y + ydelta;
}

// 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 @@ -207,7 +210,21 @@ export async function drawKeycard({
qrImages.push(await QRCode.toCanvas(key, { 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,
// wrapped 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 +237,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 +254,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 +289,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);
}
Expand Down
64 changes: 34 additions & 30 deletions modules/key-card/src/generateQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import * as assert from 'assert';
import {
GenerateLightningQrDataParams,
GenerateQrDataParams,
GenerateVaultQrDataParams,
GenerateSafeQrDataParams,
KeycardEntity,
MasterPublicKeyQrDataEntry,
QrData,
QrDataEntry,
VaultKeycardRoots,
VaultRootKeyType,
VAULT_ROOT_ORDER,
SafeKeycardRoots,
SafeRootKeyType,
SAFE_ROOT_ORDER,
} from './types';

function getPubFromKey(key: Keychain): string | undefined {
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 === 'safe' ? 'Safe' : '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 @@ -220,69 +223,70 @@ export async function generateLightningQrData(params: GenerateLightningQrDataPar
return qrData;
}

function selectRootPrivateKey(keychain: Keychain, slot: VaultRootKeyType, role: 'user' | 'backup'): string {
function selectRootPrivateKey(keychain: Keychain, slot: SafeRootKeyType, role: 'user' | 'backup'): string {
// Prefer the compact MPCv2 reduced share; fall back to encryptedPrv (e.g. multisig roots).
const data = keychain.reducedEncryptedPrv ?? keychain.encryptedPrv;
assert.ok(data, `Vault ${role} root ${slot} is missing encrypted private key material`);
assert.ok(data, `Safe ${role} root ${slot} is missing encrypted private key material`);
return data;
}

function selectRootPublicKey(keychain: Keychain, slot: VaultRootKeyType): string {
function selectRootPublicKey(keychain: Keychain, slot: SafeRootKeyType): string {
const pub = getPubFromKey(keychain);
assert.ok(pub, `Vault BitGo root ${slot} is missing a public key`);
assert.ok(pub, `Safe BitGo root ${slot} is missing a public key`);
return pub;
}

/**
* Serializes one box's four roots into a single JSON object keyed by rootKeyType, e.g.
* `{"secp256k1Multisig":"<encPrv>","ecdsaMpc":"<reducedEncPrv>",...}`. This keeps the vault
* `{"secp256k1Multisig":"<encPrv>","ecdsaMpc":"<reducedEncPrv>",...}`. This keeps the safe
* keycard on the existing 4-box layout — one box (= one QR/data block) per role — with the
* four roots packed into each box's data. `splitKeys` fragments the box if it exceeds the QR
* threshold, exactly as for a normal wallet keycard. Reversed by {@link parseVaultKeycardBox}.
* threshold, exactly as for a normal wallet keycard. Reversed by {@link parseSafeKeycardBox}.
*/
function buildVaultBoxData(
roots: Record<VaultRootKeyType, KeychainsTriplet>,
select: (root: KeychainsTriplet, slot: VaultRootKeyType) => string
function buildSafeBoxData(
roots: Record<SafeRootKeyType, KeychainsTriplet>,
select: (root: KeychainsTriplet, slot: SafeRootKeyType) => string
): string {
const box: VaultKeycardRoots = {} as VaultKeycardRoots;
for (const slot of VAULT_ROOT_ORDER) {
const box: SafeKeycardRoots = {} as SafeKeycardRoots;
for (const slot of SAFE_ROOT_ORDER) {
const root = roots[slot];
assert.ok(root, `Vault is missing the ${slot} root`);
assert.ok(root, `Safe is missing the ${slot} root`);
box[slot] = select(root, slot);
}
return JSON.stringify(box);
}

/**
* Builds vault keycard QR data in the existing wallet {@link QrData} shape: boxes A (user),
* Builds safe keycard QR data in the existing wallet {@link QrData} shape: boxes A (user),
* B (backup), C (BitGo), D (passcode). Each of A/B/C carries a JSON object of the four roots,
* so the vault renders and parses through the same {@link drawKeycard} path as a wallet.
* so the safe renders and parses through the same {@link drawKeycard} path as a wallet.
*/
export async function generateVaultQrData(params: GenerateVaultQrDataParams): Promise<QrData> {
export async function generateSafeQrData(params: GenerateSafeQrDataParams): Promise<QrData> {
const { roots } = params;
const qrData: QrData = {
user: {
title: 'A: User Key',
description: 'Your 4 root private keys, encrypted with your wallet password.',
data: buildVaultBoxData(roots, (root, slot) => selectRootPrivateKey(root.userKeychain, slot, 'user')),
description: 'Your 4 root private keys, encrypted with your safe password.',
data: buildSafeBoxData(roots, (root, slot) => selectRootPrivateKey(root.userKeychain, slot, 'user')),
},
backup: {
title: 'B: Backup Key',
description: 'Your 4 root backup keys, encrypted with your wallet password.',
data: buildVaultBoxData(roots, (root, slot) => selectRootPrivateKey(root.backupKeychain, slot, 'backup')),
description: 'Your 4 root backup keys, encrypted with your safe password.',
data: buildSafeBoxData(roots, (root, slot) => selectRootPrivateKey(root.backupKeychain, slot, 'backup')),
},
bitgo: {
title: 'C: BitGo Key',
description: 'The public parts of the 4 root keys held by BitGo.',
data: buildVaultBoxData(roots, (root, slot) => selectRootPublicKey(root.bitgoKeychain, slot)),
data: buildSafeBoxData(roots, (root, slot) => selectRootPublicKey(root.bitgoKeychain, slot)),
},
};

if (params.passphrase && params.passcodeEncryptionCode) {
qrData.passcode = await generatePasscodeQrData(
params.passphrase,
params.passcodeEncryptionCode,
params.encryptionVersion
params.encryptionVersion,
'safe'
);
}

Expand Down
16 changes: 7 additions & 9 deletions modules/key-card/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { generateLightningQrData, generateQrData, generateVaultQrData } from './generateQrData';
import { generateLightningQrData, generateQrData, generateSafeQrData } from './generateQrData';
import { generateFaq, generateLightningFaq } from './faq';
import { drawKeycard } from './drawKeycard';
import { generateParamsForKeyCreation } from './generateParamsForKeyCreation';
import {
GenerateKeycardParams,
GenerateLightningQrDataParams,
GenerateQrDataBaseParams,
GenerateVaultQrDataParams,
GenerateSafeQrDataParams,
} from './types';

export * from './drawKeycard';
Expand Down Expand Up @@ -51,16 +51,14 @@ export async function generateLightningKeycard(
}

/**
* Generates a vault keycard using the existing 4-box layout: boxes A/B/C each carry a JSON
* object of the four roots (see {@link generateVaultQrData}), rendered through the same
* {@link drawKeycard} path as a wallet. Generated as part of vault creation, once all four
* Generates a safe keycard using the existing 4-box layout: boxes A/B/C each carry a JSON
* object of the four roots (see {@link generateSafeQrData}), rendered through the same
* {@link drawKeycard} path as a wallet. Generated as part of safe creation, once all four
* root triplets exist.
*/
export async function generateVaultKeycard(
params: GenerateQrDataBaseParams & GenerateVaultQrDataParams
): Promise<void> {
export async function generateSafeKeycard(params: GenerateQrDataBaseParams & GenerateSafeQrDataParams): Promise<void> {
const questions = generateFaq(params.coin.fullName);
const qrData = await generateVaultQrData(params);
const qrData = await generateSafeQrData(params);
const keycard = await drawKeycard({ ...params, questions, qrData, pageBreakBeforeIndices: [1, 2] });
const label = params.walletLabel || params.coin.fullName;
keycard.save(`BitGo Keycard for ${label}.pdf`);
Expand Down
20 changes: 11 additions & 9 deletions modules/key-card/src/parseKeycard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { VaultKeycardRoots, VAULT_ROOT_ORDER } from './types';
import { SafeKeycardRoots, SAFE_ROOT_ORDER } from './types';

export type PDFTextNode = {
text: string;
Expand All @@ -14,28 +14,28 @@ export type KeycardEntry = {
};

/**
* Parses a vault keycard box value — the JSON packed by `generateVaultQrData`, e.g.
* 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.
*/
export function parseVaultKeycardBox(data: string): VaultKeycardRoots {
export function parseSafeKeycardBox(data: string): SafeKeycardRoots {
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
throw new Error('parseVaultKeycardBox: value is not valid JSON');
throw new Error('parseSafeKeycardBox: value is not valid JSON');
}
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('parseVaultKeycardBox: value is not an object');
throw new Error('parseSafeKeycardBox: value is not an object');
}
const roots = parsed as Record<string, unknown>;
for (const slot of VAULT_ROOT_ORDER) {
for (const slot of SAFE_ROOT_ORDER) {
if (typeof roots[slot] !== 'string') {
throw new Error(`parseVaultKeycardBox: missing or invalid root ${slot}`);
throw new Error(`parseSafeKeycardBox: missing or invalid root ${slot}`);
}
}
return parsed as VaultKeycardRoots;
return parsed as SafeKeycardRoots;
}

const sectionHeaderRegex = /^([A-D])\s*[:.)-]\s*(.+?)\s*$/i;
Expand Down Expand Up @@ -73,7 +73,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 Safe Password" (safe).
return normalized.includes('encrypted wallet password') || normalized.includes('encrypted safe password');
}

/**
Expand Down
21 changes: 12 additions & 9 deletions modules/key-card/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,28 +57,31 @@ export interface GenerateLightningQrDataParams extends GenerateQrDataCoinParams
}

/**
* Identifier for one of a vault's four roots (one per signing scheme). Each value is the
* Identifier for one of a safe's four roots (one per signing scheme). Each value is the
* root's `rootKeyType`, which is also the key used in the keycard's per-box JSON.
*/
export type VaultRootKeyType = 'secp256k1Multisig' | 'ecdsaMpc' | 'eddsaMpc' | 'ed25519Multisig';
export type SafeRootKeyType = 'secp256k1Multisig' | 'ecdsaMpc' | 'eddsaMpc' | 'ed25519Multisig';

/**
* Fixed render/scan order of the four roots on the vault keycard. Kept stable so a
* Fixed render/scan order of the four roots on the safe keycard. Kept stable so a
* generated keycard and a re-scanned one line up slot-for-slot.
*/
export const VAULT_ROOT_ORDER: VaultRootKeyType[] = ['secp256k1Multisig', 'ecdsaMpc', 'eddsaMpc', 'ed25519Multisig'];
export const SAFE_ROOT_ORDER: SafeRootKeyType[] = ['secp256k1Multisig', 'ecdsaMpc', 'eddsaMpc', 'ed25519Multisig'];

/**
* The JSON object encoded in a vault keycard box (A/B/C): the four roots keyed by
* {@link VaultRootKeyType}. Values are per-root ciphertext for A/B (encryptedPrv or
* The JSON object encoded in a safe keycard box (A/B/C): the four roots keyed by
* {@link SafeRootKeyType}. Values are per-root ciphertext for A/B (encryptedPrv or
* reducedEncryptedPrv) or public keys for C. The root-key-type keys are self-identifying, so a
* consumer parses by key rather than by size/offset.
*/
export type VaultKeycardRoots = Record<VaultRootKeyType, string>;
export type SafeKeycardRoots = Record<SafeRootKeyType, string>;

export interface GenerateVaultQrDataParams extends GenerateQrDataCoinParams {
/** The product a keycard belongs to, used for user-facing wording (e.g. Box D copy). */
export type KeycardEntity = 'wallet' | 'safe';

export interface GenerateSafeQrDataParams extends GenerateQrDataCoinParams {
// The four root triplets (user/backup/bitgo keychains), keyed by rootKeyType.
roots: Record<VaultRootKeyType, KeychainsTriplet>;
roots: Record<SafeRootKeyType, KeychainsTriplet>;
}

export type GenerateKeycardParams = GenerateQrDataBaseParams &
Expand Down
Loading
Loading