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
10 changes: 8 additions & 2 deletions modules/key-card/src/drawKeycard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ enum KeyCurveName {
// this limitation was chosen by trial and error
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.
const DEFAULT_PAGE_BREAK_INDICES = [2];

const font = {
header: 24,
subheader: 15,
Expand Down Expand Up @@ -110,6 +114,7 @@ export async function drawKeycard({
qrData,
walletLabel,
curve,
pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES,
}: IDrawKeyCard): Promise<jsPDF> {
const jsPDFModule = await loadJSPDF();

Expand Down Expand Up @@ -182,8 +187,9 @@ export async function drawKeycard({
);
for (let index = 0; index < qrKeys.length; index++) {
const name = qrKeys[index];
if (index === 2) {
// Add 2nd Page

if (pageBreakBeforeIndices.includes(index)) {
// Start a new page for this box
doc.addPage();

// 2nd page title
Expand Down
75 changes: 74 additions & 1 deletion modules/key-card/src/generateQrData.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { BaseCoin } from '@bitgo/statics';
import { Keychain } from '@bitgo/sdk-core';
import { Keychain, KeychainsTriplet } from '@bitgo/sdk-core';
import { encrypt } from '@bitgo/sdk-api';
import * as assert from 'assert';
import {
GenerateLightningQrDataParams,
GenerateQrDataParams,
GenerateVaultQrDataParams,
MasterPublicKeyQrDataEntry,
QrData,
QrDataEntry,
VaultKeycardRoots,
VaultRootKeyType,
VAULT_ROOT_ORDER,
} from './types';

function getPubFromKey(key: Keychain): string | undefined {
Expand Down Expand Up @@ -215,3 +219,72 @@ export async function generateLightningQrData(params: GenerateLightningQrDataPar

return qrData;
}

function selectRootPrivateKey(keychain: Keychain, slot: VaultRootKeyType, 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`);
return data;
}

function selectRootPublicKey(keychain: Keychain, slot: VaultRootKeyType): string {
const pub = getPubFromKey(keychain);
assert.ok(pub, `Vault 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
* 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}.
*/
function buildVaultBoxData(
roots: Record<VaultRootKeyType, KeychainsTriplet>,
select: (root: KeychainsTriplet, slot: VaultRootKeyType) => string
): string {
const box: VaultKeycardRoots = {} as VaultKeycardRoots;
for (const slot of VAULT_ROOT_ORDER) {
const root = roots[slot];
assert.ok(root, `Vault 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),
* 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.
*/
export async function generateVaultQrData(params: GenerateVaultQrDataParams): 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')),
},
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')),
},
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)),
},
};

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

return qrData;
}
25 changes: 23 additions & 2 deletions modules/key-card/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { generateLightningQrData, generateQrData } from './generateQrData';
import { generateLightningQrData, generateQrData, generateVaultQrData } from './generateQrData';
import { generateFaq, generateLightningFaq } from './faq';
import { drawKeycard } from './drawKeycard';
import { generateParamsForKeyCreation } from './generateParamsForKeyCreation';
import { GenerateKeycardParams, GenerateLightningQrDataParams, GenerateQrDataBaseParams } from './types';
import {
GenerateKeycardParams,
GenerateLightningQrDataParams,
GenerateQrDataBaseParams,
GenerateVaultQrDataParams,
} from './types';

export * from './drawKeycard';
export * from './extractKeycardFromPDF';
Expand Down Expand Up @@ -44,3 +49,19 @@ export async function generateLightningKeycard(
const label = params.walletLabel || params.coin.fullName;
keycard.save(`BitGo Keycard for ${label}.pdf`);
}

/**
* 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
* root triplets exist.
*/
export async function generateVaultKeycard(
params: GenerateQrDataBaseParams & GenerateVaultQrDataParams
): Promise<void> {
const questions = generateFaq(params.coin.fullName);
const qrData = await generateVaultQrData(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`);
}
27 changes: 27 additions & 0 deletions modules/key-card/src/parseKeycard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { VaultKeycardRoots, VAULT_ROOT_ORDER } from './types';

export type PDFTextNode = {
text: string;
x: number;
Expand All @@ -11,6 +13,31 @@ export type KeycardEntry = {
value: string;
};

/**
* 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.
*/
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}`);
}
}
return parsed as VaultKeycardRoots;
Comment thread
s84krish marked this conversation as resolved.
}

const sectionHeaderRegex = /^([A-D])\s*[:.)-]\s*(.+?)\s*$/i;
const dataLineRegex = /^data\s*:\s*(.*)$/i;
const faqHeaderRegex = /^BitGo\s+KeyCard\s+FAQ$/i;
Expand Down
29 changes: 28 additions & 1 deletion modules/key-card/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EncryptionVersion, Keychain } from '@bitgo/sdk-core';
import { EncryptionVersion, Keychain, KeychainsTriplet } from '@bitgo/sdk-core';
import { BaseCoin, KeyCurve } from '@bitgo/statics';

export interface GenerateQrDataBaseParams {
Expand Down Expand Up @@ -56,6 +56,31 @@ export interface GenerateLightningQrDataParams extends GenerateQrDataCoinParams
userAuthKeychain: Keychain;
}

/**
* Identifier for one of a vault'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';

/**
* Fixed render/scan order of the four roots on the vault 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'];

/**
* 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
* 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 interface GenerateVaultQrDataParams extends GenerateQrDataCoinParams {
// The four root triplets (user/backup/bitgo keychains), keyed by rootKeyType.
roots: Record<VaultRootKeyType, KeychainsTriplet>;
}

export type GenerateKeycardParams = GenerateQrDataBaseParams &
(GenerateQrDataForKeychainParams | GenerateQrDataParams | GenerateLightningQrDataParams);

Expand All @@ -66,6 +91,8 @@ export interface IDrawKeyCard {
questions: FAQ[];
walletLabel?: string;
curve?: KeyCurve;
// Box indices to start a new page before. Omit for the default wallet layout.
Comment thread
s84krish marked this conversation as resolved.
pageBreakBeforeIndices?: number[];
}

export interface FAQ {
Expand Down
Loading
Loading