From 15fdb259d54480f065b53ad47510e3b5ce5c57ea Mon Sep 17 00:00:00 2001 From: Sibi Krishnan Date: Wed, 8 Jul 2026 11:09:46 -0400 Subject: [PATCH] feat: multi-root layout in keycard module for wallet-vaults Ticket: WCN-1193 --- modules/key-card/src/drawKeycard.ts | 10 +- modules/key-card/src/generateQrData.ts | 75 ++++++++- modules/key-card/src/index.ts | 25 ++- modules/key-card/src/parseKeycard.ts | 27 +++ modules/key-card/src/types.ts | 29 +++- modules/key-card/test/unit/vaultQrData.ts | 194 ++++++++++++++++++++++ 6 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 modules/key-card/test/unit/vaultQrData.ts diff --git a/modules/key-card/src/drawKeycard.ts b/modules/key-card/src/drawKeycard.ts index a37d093b9c..4fb7488cf7 100644 --- a/modules/key-card/src/drawKeycard.ts +++ b/modules/key-card/src/drawKeycard.ts @@ -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, @@ -110,6 +114,7 @@ export async function drawKeycard({ qrData, walletLabel, curve, + pageBreakBeforeIndices = DEFAULT_PAGE_BREAK_INDICES, }: IDrawKeyCard): Promise { const jsPDFModule = await loadJSPDF(); @@ -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 diff --git a/modules/key-card/src/generateQrData.ts b/modules/key-card/src/generateQrData.ts index c01232a2cc..5bf437770b 100644 --- a/modules/key-card/src/generateQrData.ts +++ b/modules/key-card/src/generateQrData.ts @@ -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 { @@ -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":"","ecdsaMpc":"",...}`. 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, + 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 { + 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; +} diff --git a/modules/key-card/src/index.ts b/modules/key-card/src/index.ts index 6409632a0c..617a8c43b3 100644 --- a/modules/key-card/src/index.ts +++ b/modules/key-card/src/index.ts @@ -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'; @@ -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 { + 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`); +} diff --git a/modules/key-card/src/parseKeycard.ts b/modules/key-card/src/parseKeycard.ts index 83e9dd0c3e..98a972acfb 100644 --- a/modules/key-card/src/parseKeycard.ts +++ b/modules/key-card/src/parseKeycard.ts @@ -1,3 +1,5 @@ +import { VaultKeycardRoots, VAULT_ROOT_ORDER } from './types'; + export type PDFTextNode = { text: string; x: number; @@ -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; + 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; +} + const sectionHeaderRegex = /^([A-D])\s*[:.)-]\s*(.+?)\s*$/i; const dataLineRegex = /^data\s*:\s*(.*)$/i; const faqHeaderRegex = /^BitGo\s+KeyCard\s+FAQ$/i; diff --git a/modules/key-card/src/types.ts b/modules/key-card/src/types.ts index 49d1523e78..79d2092eee 100644 --- a/modules/key-card/src/types.ts +++ b/modules/key-card/src/types.ts @@ -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 { @@ -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; + +export interface GenerateVaultQrDataParams extends GenerateQrDataCoinParams { + // The four root triplets (user/backup/bitgo keychains), keyed by rootKeyType. + roots: Record; +} + export type GenerateKeycardParams = GenerateQrDataBaseParams & (GenerateQrDataForKeychainParams | GenerateQrDataParams | GenerateLightningQrDataParams); @@ -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. + pageBreakBeforeIndices?: number[]; } export interface FAQ { diff --git a/modules/key-card/test/unit/vaultQrData.ts b/modules/key-card/test/unit/vaultQrData.ts new file mode 100644 index 0000000000..8375f53457 --- /dev/null +++ b/modules/key-card/test/unit/vaultQrData.ts @@ -0,0 +1,194 @@ +import 'should'; +import * as assert from 'assert'; +import { decrypt, encrypt } from '@bitgo/sdk-api'; +import { coins } from '@bitgo/statics'; +import { Keychain, KeychainsTriplet, KeyType } from '@bitgo/sdk-core'; +import { generateVaultQrData } from '../../src/generateQrData'; +import { splitKeys } from '../../src/utils'; +import { QRBinaryMaxLength } from '../../src/drawKeycard'; +import { parseKeycardFromLines, parseVaultKeycardBox } from '../../src/parseKeycard'; +import { VaultRootKeyType, VAULT_ROOT_ORDER } from '../../src/types'; + +const passphrase = 'vault-keycard-round-trip'; + +// Courier chars per line when synthesizing the "Data:" block of a scanned PDF (see the parser +// round-trip test); any value works — it only exercises line reassembly in parseKeycardFromLines. +const SYNTHETIC_PDF_LINE_WIDTH = 64; + +function makeKeychain(overrides: Partial): Keychain { + return { id: 'id', type: 'independent', pub: 'pub', ...overrides }; +} + +/** + * Plaintext byte-length of the private material each root actually stores, measured from the + * real key generators: + * - multisig roots → a BIP32 xprv, 111 chars (`utxolib.bip32.fromSeed().toBase58()`) + * - ecdsaMpc → base64 of the DKLS reduced key share, ~808 chars + * (`DklsUtils.generateDKGKeyShares()[i].getReducedKeyShare()`) + * - eddsaMpc → base64 of the EdDSA reduced key share, ~548 chars + * (`MPSUtil.generateEdDsaDKGKeyShares()[i].getReducedKeyShare()`) + * Using the real lengths keeps the round-trip and QR-split assertions representative. + */ +const ROOT_PLAINTEXT_LENGTHS: Record = { + secp256k1Multisig: 111, + ecdsaMpc: 808, + eddsaMpc: 548, + ed25519Multisig: 111, +}; + +// Distinct, correctly-sized plaintext per (slot, role) — the prefix makes each value unique; +// padEnd brings it to the real length above. +function plaintextFor(slot: VaultRootKeyType, role: string): string { + return `${slot}:${role}:`.padEnd(ROOT_PLAINTEXT_LENGTHS[slot], 'x'); +} + +async function buildRoots(): Promise<{ + roots: Record; + plaintexts: Record; + bitgoPubs: Record; +}> { + const roots = {} as Record; + const plaintexts = {} as Record; + const bitgoPubs = {} as Record; + + for (const slot of VAULT_ROOT_ORDER) { + const isMpc = slot === 'ecdsaMpc' || slot === 'eddsaMpc'; + const keyType: KeyType = isMpc ? 'tss' : 'independent'; + const userPrv = plaintextFor(slot, 'user'); + const backupPrv = plaintextFor(slot, 'backup'); + const bitgoPub = `${slot}-bitgo-pub`; + + plaintexts[slot] = { user: userPrv, backup: backupPrv }; + bitgoPubs[slot] = bitgoPub; + + // MPC roots carry the encrypted material in reducedEncryptedPrv; multisig in encryptedPrv. + const encPrv = async (plain: string) => await encrypt(passphrase, plain); + roots[slot] = { + userKeychain: isMpc + ? makeKeychain({ type: keyType, commonKeychain: bitgoPub, reducedEncryptedPrv: await encPrv(userPrv) }) + : makeKeychain({ type: keyType, encryptedPrv: await encPrv(userPrv) }), + backupKeychain: isMpc + ? makeKeychain({ type: keyType, commonKeychain: bitgoPub, reducedEncryptedPrv: await encPrv(backupPrv) }) + : makeKeychain({ type: keyType, encryptedPrv: await encPrv(backupPrv) }), + bitgoKeychain: isMpc + ? makeKeychain({ type: keyType, commonKeychain: bitgoPub }) + : makeKeychain({ type: keyType, pub: bitgoPub }), + }; + } + + return { roots, plaintexts, bitgoPubs }; +} + +// Reassemble a payload from its QR fragments the same way a scanner would. +function reassemble(data: string): string { + return splitKeys(data, QRBinaryMaxLength).join(''); +} + +describe('generateVaultQrData', function () { + // Argon2id encryption of several root payloads is CPU-heavy. + this.timeout(30000); + + it('produces the existing 4-box QrData shape, each box a JSON object of the 4 roots', async function () { + const { roots } = await buildRoots(); + const qrData = await generateVaultQrData({ coin: coins.get('btc'), roots }); + + qrData.user.title.should.equal('A: User Key'); + qrData.backup?.title.should.equal('B: Backup Key'); + qrData.bitgo?.title.should.equal('C: BitGo Key'); + + // Each box parses via parseVaultKeycardBox into the four root slots, in order. + for (const box of [qrData.user, qrData.backup, qrData.bitgo]) { + assert.ok(box); + Object.keys(parseVaultKeycardBox(box.data)).should.deepEqual([...VAULT_ROOT_ORDER]); + } + }); + + it('round-trips: reassemble box -> JSON.parse -> decrypt all 4 user + backup roots', async function () { + const { roots, plaintexts, bitgoPubs } = await buildRoots(); + const qrData = await generateVaultQrData({ coin: coins.get('btc'), roots }); + assert.ok(qrData.backup && qrData.bitgo); + + const userBox = parseVaultKeycardBox(reassemble(qrData.user.data)); + const backupBox = parseVaultKeycardBox(reassemble(qrData.backup.data)); + const bitgoBox = parseVaultKeycardBox(reassemble(qrData.bitgo.data)); + + for (const slot of VAULT_ROOT_ORDER) { + (await decrypt(passphrase, userBox[slot])).should.equal(plaintexts[slot].user); + (await decrypt(passphrase, backupBox[slot])).should.equal(plaintexts[slot].backup); + bitgoBox[slot].should.equal(bitgoPubs[slot]); + } + }); + + it('uses reducedEncryptedPrv for MPC roots and encryptedPrv for multisig roots', async function () { + const { roots } = await buildRoots(); + const qrData = await generateVaultQrData({ coin: coins.get('btc'), roots }); + const userBox = parseVaultKeycardBox(qrData.user.data); + + // The reduced (MPC) value must equal the keychain's reducedEncryptedPrv; multisig the encryptedPrv. + userBox['ecdsaMpc'].should.equal(roots.ecdsaMpc.userKeychain.reducedEncryptedPrv); + userBox['eddsaMpc'].should.equal(roots.eddsaMpc.userKeychain.reducedEncryptedPrv); + userBox['secp256k1Multisig'].should.equal(roots.secp256k1Multisig.userKeychain.encryptedPrv); + userBox['ed25519Multisig'].should.equal(roots.ed25519Multisig.userKeychain.encryptedPrv); + }); + + it('includes box D when passphrase + passcodeEncryptionCode are provided', async function () { + const { roots } = await buildRoots(); + const qrData = await generateVaultQrData({ + coin: coins.get('btc'), + roots, + passphrase: 'wallet-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'); + }); + + it('throws when a user root is missing private key material', async function () { + const { roots } = await buildRoots(); + roots.eddsaMpc.userKeychain = makeKeychain({ type: 'tss' }); + await assert.rejects(() => generateVaultQrData({ coin: coins.get('btc'), roots }), /missing encrypted private key/); + }); + + it('throws a clear error when an entire root triplet is missing', async function () { + const { roots } = await buildRoots(); + delete (roots as Partial>).ecdsaMpc; + await assert.rejects(() => generateVaultQrData({ coin: coins.get('btc'), roots }), /missing the ecdsaMpc root/); + }); + + it('recovers all 4 roots through the existing PDF-line parser + JSON.parse', async function () { + const { roots, plaintexts } = await buildRoots(); + const qrData = await generateVaultQrData({ coin: coins.get('btc'), roots }); + assert.ok(qrData.backup && qrData.bitgo); + + // Synthesize the text lines a PDF scan of a wallet-format keycard produces. + const lines: string[] = []; + const boxes: Array<[string, string, string]> = [ + ['A', 'User Key', qrData.user.data], + ['B', 'Backup Key', qrData.backup.data], + ['C', 'BitGo Key', qrData.bitgo.data], + ]; + for (const [section, title, data] of boxes) { + lines.push(`${section}: ${title}`); + lines.push('Data:'); + for (let i = 0; i < data.length; i += SYNTHETIC_PDF_LINE_WIDTH) { + lines.push(data.slice(i, i + SYNTHETIC_PDF_LINE_WIDTH)); + } + } + lines.push('BitGo KeyCard FAQ'); + + const entries = parseKeycardFromLines(lines); + const userEntry = entries.find((e) => e.label.startsWith('A')); + assert.ok(userEntry); + const userBox = parseVaultKeycardBox(userEntry.value); + for (const slot of VAULT_ROOT_ORDER) { + (await decrypt(passphrase, userBox[slot])).should.equal(plaintexts[slot].user); + } + }); + + 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/); + }); +});