Skip to content
Draft
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
7 changes: 5 additions & 2 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,7 @@ export async function handleV2GenerateSafe(
req: ExpressApiRouteRequest<'express.v2.safes.generate', 'post'>
): Promise<any> {
const safes = new Safes(req.bitgo, req.decoded.enterpriseId);
const safe = await safes.generateSafe({
const { safe } = await safes.generateSafe({
label: req.decoded.label,
passphrase: req.decoded.passphrase,
});
Expand Down Expand Up @@ -751,12 +751,15 @@ export async function handleV2GenerateSafeWallet(
*/
export async function handleV2GenerateSafeKeys(req: ExpressApiRouteRequest<'express.v2.safes.keys.generate', 'post'>) {
const safes = new Safes(req.bitgo, req.decoded.enterpriseId);
return safes.createSafeKeys({
const created = await safes.createSafeKeys({
label: '',
passphrase: req.decoded.passphrase,
safeId: req.decoded.safeId,
enabledRootSlots: req.decoded.enabledRootSlots,
});
// The root triplets are client-side keycard material (encrypted private keys); the express
// wire contract stays the documented one — only the root key ids.
return { rootKeys: created.rootKeys };
}

/**
Expand Down
12 changes: 8 additions & 4 deletions modules/express/test/unit/typedRoutes/generateSafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,12 @@ describe('Generate Safe Typed Routes Tests', function () {
const label = 'Test Safe';
const passphrase = 'mySecurePassphrase123';
const safeJson = mockSafeJson(label);
const generateSafeStub = sinon.stub(Safes.prototype, 'generateSafe').resolves(
stubSafe({
const generateSafeStub = sinon.stub(Safes.prototype, 'generateSafe').resolves({
safe: stubSafe({
toJSON: () => safeJson,
})
);
}),
rootKeychains: {},
});

const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({
label,
Expand Down Expand Up @@ -222,6 +223,7 @@ describe('Generate Safe Typed Routes Tests', function () {
secp256k1Multisig: ['user-1', 'backup-1', 'bitgo-1'] as [string, string, string],
},
},
rootKeychains: {},
};

it('should run phase-2 ceremonies and return root key ids', async function () {
Expand All @@ -235,6 +237,8 @@ describe('Generate Safe Typed Routes Tests', function () {

res.status.should.equal(200);
res.body.should.have.property('rootKeys');
// The express contract returns only the ids; the root triplets stay client-side.
res.body.should.not.have.property('rootKeychains');
res.body.rootKeys.hot.secp256k1Multisig.should.deepEqual(['user-1', 'backup-1', 'bitgo-1']);
createSafeKeysStub.should.have.been.calledOnce();
createSafeKeysStub.firstCall.args[0].should.have.property('passphrase', passphrase);
Expand Down
33 changes: 22 additions & 11 deletions modules/key-card/src/generateQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,19 +247,24 @@ function selectRootPublicKey(keychain: Keychain, slot: SafeRootKeyType): string
}

/**
* Serializes one box's four roots into a single JSON object keyed by rootKeyType, e.g.
* `{"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 parseSafeKeycardBox}.
* Serializes one box's roots into a single JSON object keyed by rootKeyType, e.g.
* `{"secp256k1Multisig":"<encPrv>","ecdsaMpc":"<reducedEncPrv>",...}`. Only the root slots the
* safe actually carries are packed, in canonical order, so partial-slot safes render a
* keycard too. This keeps the safe keycard on the existing 4-box layout — one box (= one
* QR/data block) per role — with the 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 parseSafeKeycardBox}.
*/
function buildSafeBoxData(
roots: Record<SafeRootKeyType, KeychainsTriplet>,
roots: Partial<Record<SafeRootKeyType, KeychainsTriplet>>,
select: (root: KeychainsTriplet, slot: SafeRootKeyType) => SafeKeycardRootEntry
): string {
const box: SafeKeycardRoots = {} as SafeKeycardRoots;
for (const slot of SAFE_ROOT_ORDER) {
const root = roots[slot];
if (root === undefined) {
continue;
}
assert.ok(root, `Safe is missing the ${slot} root`);
box[slot] = select(root, slot);
}
Expand All @@ -268,25 +273,31 @@ function buildSafeBoxData(

/**
* 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 safe renders and parses through the same {@link drawKeycard} path as a wallet.
* B (backup), C (BitGo), D (passcode). Each of A/B/C carries a JSON object of the safe's
* root slots, so the safe renders and parses through the same {@link drawKeycard} path as
* a wallet. Requires at least one root triplet.
*/
export async function generateSafeQrData(params: GenerateSafeQrDataParams): Promise<QrData> {
const { roots } = params;
// Count present slots, not keys: `{ ecdsaMpc: undefined }` would otherwise print a keycard
// whose A/B/C boxes are all `{}`, which parseSafeKeycardBox later rejects.
if (SAFE_ROOT_ORDER.every((slot) => roots[slot] === undefined)) {
throw new Error('safe keycard requires at least one root triplet');
}
const qrData: QrData = {
user: {
title: 'A: User Key',
description: 'Your 4 root private keys, encrypted with your safe password.',
description: 'Your 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 safe password.',
description: 'Your root backup keys, encrypted with your safe password.',
data: buildSafeBoxData(roots, (root, slot) => selectBackupRootEntry(root.backupKeychain, slot)),
},
bitgo: {
title: 'C: BitGo Key',
description: 'The public parts of the 4 root keys held by BitGo.',
description: 'The public parts of the root keys held by BitGo.',
data: buildSafeBoxData(roots, (root, slot) => selectRootPublicKey(root.bitgoKeychain, slot)),
},
};
Expand Down
23 changes: 21 additions & 2 deletions modules/key-card/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export async function generateLightningKeycard(

/**
* 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
* object of the safe's root slots (see {@link generateSafeQrData}), rendered through the same
* {@link drawKeycard} path as a wallet. Generated as part of safe creation, once the safe's
* root triplets exist.
*/
export async function generateSafeKeycard(params: GenerateQrDataBaseParams & GenerateSafeQrDataParams): Promise<void> {
Expand All @@ -70,3 +70,22 @@ export async function generateSafeKeycard(params: GenerateQrDataBaseParams & Gen
const label = params.walletLabel || params.coin.fullName;
keycard.save(`BitGo Keycard for ${label}.pdf`);
}

/**
* Same as {@link generateSafeKeycard} but returns the PDF as a {@link Blob} instead of saving
* it to disk, for callers (e.g. mobile) that stream the bytes themselves.
*/
export async function generateSafeKeycardBlob(
params: GenerateQrDataBaseParams & GenerateSafeQrDataParams
): Promise<Blob> {
const questions = generateFaq(params.coin.fullName);
const qrData = await generateSafeQrData(params);
const keycard = await drawKeycard({
...params,
questions,
qrData,
pageBreakBeforeIndices: [1, 2],
useQrPartHeaders: true,
});
return keycard.output('blob');
}
5 changes: 3 additions & 2 deletions modules/key-card/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ export const SAFE_ROOT_ORDER = Object.keys(slotsInRenderOrder) as SafeRootKeyTyp
export type KeycardEntity = 'wallet' | 'safe';

export interface GenerateSafeQrDataParams extends GenerateQrDataCoinParams {
// The four root triplets (user/backup/bitgo keychains), keyed by rootKeyType.
roots: Record<SafeRootKeyType, KeychainsTriplet>;
// The safe's root triplets (user/backup/bitgo keychains), keyed by rootKeyType. Any non-empty
// subset of the four slots is valid; the keycard carries exactly the slots present here.
roots: Partial<Record<SafeRootKeyType, KeychainsTriplet>>;
}

export type GenerateKeycardParams = GenerateQrDataBaseParams &
Expand Down
52 changes: 47 additions & 5 deletions modules/key-card/test/unit/safeQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseMpcV2KeyShareEnvelope,
} from '@bitgo/sdk-core';
import { generateSafeQrData } from '../../src/generateQrData';
import { generateSafeKeycardBlob } from '../../src';
import { splitKeys } from '../../src/utils';
import { QRBinaryMaxLength } from '../../src/drawKeycard';
import { parseKeycardFromLines, parseSafeKeycardBox } from '../../src/parseKeycard';
Expand Down Expand Up @@ -223,10 +224,49 @@ describe('generateSafeQrData', function () {
await assert.rejects(() => generateSafeQrData({ coin: coins.get('btc'), roots }), /missing encrypted private key/);
});

it('throws a clear error when an entire root triplet is missing', async function () {
it('round-trips a 2-slot subset: only the declared slots appear in every box', async function () {
const { roots, plaintexts, bitgoPubs } = await buildRoots();
const subset: Partial<Record<SafeRootKeyType, KeychainsTriplet>> = {
secp256k1Multisig: roots.secp256k1Multisig,
ecdsaMpc: roots.ecdsaMpc,
};
const qrData = await generateSafeQrData({ coin: coins.get('btc'), roots: subset });
assert.ok(qrData.backup && qrData.bitgo);

const expectedKeys = ['secp256k1Multisig', 'ecdsaMpc'];
for (const box of [qrData.user, qrData.backup, qrData.bitgo]) {
assert.ok(box);
Object.keys(parseSafeKeycardBox(box.data)).should.deepEqual(expectedKeys);
}

const userBox = parseSafeKeycardBox(qrData.user.data);
const backupBox = parseSafeKeycardBox(qrData.backup.data);
const bitgoBox = parseSafeKeycardBox(qrData.bitgo.data);
for (const slot of expectedKeys) {
(await decrypt(passphrase, userBox[slot] as string)).should.equal(plaintexts[slot].user);
(await decrypt(passphrase, backupBox[slot] as string)).should.equal(plaintexts[slot].backup);
bitgoBox[slot].should.equal(bitgoPubs[slot]);
}
});

it('throws when roots is empty', async function () {
await assert.rejects(() => generateSafeQrData({ coin: coins.get('btc'), roots: {} }), /at least one root triplet/);
// An explicitly undefined slot is not a present slot.
await assert.rejects(
() => generateSafeQrData({ coin: coins.get('btc'), roots: { ecdsaMpc: undefined } }),
/at least one root triplet/
);
});

it('generateSafeKeycardBlob returns a non-empty PDF blob without saving to disk', async function () {
const { roots } = await buildRoots();
delete (roots as Partial<Record<SafeRootKeyType, KeychainsTriplet>>).ecdsaMpc;
await assert.rejects(() => generateSafeQrData({ coin: coins.get('btc'), roots }), /missing the ecdsaMpc root/);
const subset: Partial<Record<SafeRootKeyType, KeychainsTriplet>> = {
secp256k1Multisig: roots.secp256k1Multisig,
ed25519Multisig: roots.ed25519Multisig,
};
const blob = await generateSafeKeycardBlob({ coin: coins.get('btc'), roots: subset });
assert.ok(blob instanceof Blob, 'expected a Blob');
assert.ok(blob.size > 0, 'expected a non-empty PDF blob');
});

it('recovers all 4 roots through the existing PDF-line parser + JSON.parse', async function () {
Expand Down Expand Up @@ -262,9 +302,11 @@ describe('generateSafeQrData', function () {
it('parseSafeKeycardBox rejects malformed / incomplete box data', function () {
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
assert.throws(() => parseSafeKeycardBox('{}'), /empty slot set/); // declares no roots
assert.throws(() => parseSafeKeycardBox('{"secp256k1Multisig":"a","unknownRoot":"b"}'), /unknownRoot/);
parseSafeKeycardBox('{"secp256k1Multisig":"x"}').should.deepEqual({ secp256k1Multisig: 'x' }); // a subset now parses
// 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');
ok.ecdsaMpc!.should.equal('b');
});
});
3 changes: 3 additions & 0 deletions modules/sdk-core/src/bitgo/keychain/iKeychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ export interface CreateBackupOptions {
encryptedPrv?: string;
passphrase?: string;
encryptionVersion?: EncryptionVersion;
/** Code the backup root's encryptedPrv is additionally wrapped under server-side, so a
* regenerated keycard can re-derive it (WCN-3922). Forwarded by `add` on the wire. @experimental */
originalPasscodeEncryptionCode?: string;
/** Public id of the safe this backup key is a root of (Wallet Safes v1). @experimental */
safeId?: string;
}
Expand Down
17 changes: 17 additions & 0 deletions modules/sdk-core/src/bitgo/safe/iSafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
import type { EncryptionVersion } from '../../api/types';
import type { Wallet, WalletShare } from '../wallet';
import type { WebAuthnProvider } from '../webauthn';
import type { SafeKeycardAccess, SafeKeycardMaterial, SafeKeycardPendingApproval } from './keycard';

/** @experimental */
export interface InitializeSafeOptions {
Expand Down Expand Up @@ -139,6 +140,22 @@ export interface ISafe {
acceptShare(params: AcceptSafeShareOptions): Promise<SafeShareData>;
registerPasskey(params: RegisterSafePasskeyOptions): Promise<RegisterSafePasskeyResponse>;
removePasskey(params: RemoveSafePasskeyOptions): Promise<void>;
/**
* Request keycard regeneration access for this safe (WCN-3921 route contract). Returns the
* granted access window, or the pending approval the request created when policy requires
* one. Gated server-side by the `wp_safe_keycard_access` flag.
* @experimental
*/
requestKeycardAccess(): Promise<
{ keycardAccess: SafeKeycardAccess } | { pendingApproval: SafeKeycardPendingApproval }
>;
/**
* Claim the safe's keycard material (root triplets + optional passcode encryption code)
* once keycard access has been granted. Wire shapes are decoded minimally; the exact PA
* serialization is owned by the WP routes.
* @experimental
*/
claimKeycardMaterial(): Promise<SafeKeycardMaterial>;
freeze(params?: FreezeSafeBody): Promise<SafeData>;
archive(): Promise<SafeData>;
toJSON(): SafeData;
Expand Down
18 changes: 14 additions & 4 deletions modules/sdk-core/src/bitgo/safe/iSafes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import type { InitializeSafeResponse } from '@bitgo/public-types';
import { FinalizeSafeOptions, InitializeSafeOptions } from './iSafe';
import type { SafeKeychainRoots } from './keycard';
import { Safe } from './safe';

/**
Expand All @@ -20,6 +21,8 @@ import { Safe } from './safe';
export interface CreateSafeOptions {
label: string;
passphrase: string; // encrypts the locally-generated multisig user/backup prvs; shared with the MPC ceremonies
/** Generated by the caller and stored on the roots as `originalPasscodeEncryptionCode`; absent ⇒ no Box D. @experimental */
passcodeEncryptionCode?: string;
}

/**
Expand All @@ -35,11 +38,18 @@ export interface SafeCreationHandle {
}

/**
* The minted root key ids produced by `createSafeKeys`, as ordered [user, backup, bitgo]
* triplets for the enabled slots — exactly the payload `finalizeSafe` consumes.
* The minted root keys produced by `createSafeKeys`: the ordered [user, backup, bitgo] id
* triplets `finalizeSafe` consumes, plus the full keychain triplets (including each root's
* `encryptedPrv` / `reducedEncryptedPrv`) the caller needs to generate the safe keycard.
* @experimental
*/
export type SafeKeys = FinalizeSafeOptions;
export type SafeKeys = FinalizeSafeOptions & { rootKeychains: SafeKeychainRoots };

/** Result of the one-call `generateSafe`: the finalized safe plus its root keychain triplets. */
export interface SafeCreationResult {
safe: Safe;
rootKeychains: SafeKeychainRoots;
}

/**
* @experimental
Expand All @@ -66,7 +76,7 @@ export interface ISafes {
* HOT custody only in v1.
* @experimental
*/
generateSafe(params: CreateSafeOptions): Promise<Safe>;
generateSafe(params: CreateSafeOptions): Promise<SafeCreationResult>;
/**
* Phase 1 — initialize a safe (metadata only, no key material). The server response is
* `{ id, status }` plus optional `enabledRootSlots` (no `label`/`enterpriseId`/`creator`/`users`/
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/safe/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './derivableEd25519Pub';
export * from './iSafe';
export * from './iSafes';
export * from './keycard';
export * from './recovery';
export * from './safe';
export * from './safes';
Expand Down
Loading
Loading