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
2 changes: 1 addition & 1 deletion modules/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"@api-ts/typed-express-router": "2.0.0",
"@bitgo/abstract-lightning": "^8.6.3",
"@bitgo/logger": "^1.4.0",
"@bitgo/public-types": "6.90.0",
"@bitgo/public-types": "7.1.0",
"@bitgo/sdk-core": "^40.3.0",
"@bitgo/utxo-lib": "^11.25.0",
"@types/proxyquire": "^1.3.31",
Expand Down
3 changes: 3 additions & 0 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ export async function handleV2GenerateSafe(
const safe = await safes.generateSafe({
label: req.decoded.label,
passphrase: req.decoded.passphrase,
...(req.decoded.multisigRoots === undefined ? {} : { multisigRoots: req.decoded.multisigRoots }),
});
return safe.toJSON();
}
Expand Down Expand Up @@ -755,6 +756,8 @@ export async function handleV2GenerateSafeKeys(req: ExpressApiRouteRequest<'expr
passphrase: req.decoded.passphrase,
safeId: req.decoded.safeId,
enabledRootSlots: req.decoded.enabledRootSlots,
multisigRoots: req.decoded.multisigRoots,
externalBackupSlots: req.decoded.externalBackupSlots,
});
}

Expand Down
26 changes: 25 additions & 1 deletion modules/express/src/typedRoutes/api/v2/generateSafe.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import * as t from 'io-ts';
import { httpRoute, httpRequest } from '@api-ts/io-ts-http';
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
import { BitgoExpressError } from '../../schemas/error';

/**
* A caller-supplied multisig backup root (bring your own key). The backup private key never reaches
* BitGo Express.
*/
export const SafeMultisigRootKeyOptions = t.type({
/** Slot ①: the backup root xpub. Slot ④: an ed25519 StrKey, or a 103-character derivable public key */
backupXpub: t.string,
});

/**
* Caller-supplied backup roots, keyed by multisig slot. Only `secp256k1Multisig` and `ed25519Multisig`
* are accepted; any other slot is rejected before a ceremony runs.
*/
export const SafeMultisigRoots = t.partial({
secp256k1Multisig: SafeMultisigRootKeyOptions,
ed25519Multisig: SafeMultisigRootKeyOptions,
});

/**
* Path parameters for one-shot Safe generation.
*/
Expand All @@ -20,6 +38,12 @@ export const GenerateSafeBody = {
label: t.string,
/** Passphrase used to encrypt locally generated root user/backup keys and to run MPC ceremonies */
passphrase: t.string,
/**
* Bring-your-own backup key per multisig slot. A listed slot gets this backup root public key instead
* of a locally generated backup key; the SDK never sees its private key. Declared to BitGo at
* initialize, so it also needs the external backup root feature enabled for the enterprise.
*/
multisigRoots: optional(SafeMultisigRoots),
} as const;

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as t from 'io-ts';
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
import { RootKeyType } from '@bitgo/public-types';
import { MultisigRootKeyType, RootKeyType } from '@bitgo/public-types';
import { BitgoExpressError } from '../../schemas/error';
import { SafeMultisigRoots } from './generateSafe';

/**
* Path parameters for Phase-2-only Safe key ceremonies.
Expand All @@ -23,6 +24,10 @@ export const GenerateSafeKeysBody = {
passphrase: t.string,
/** Slots to run. Omit to run all four (older WP / retry of a full ceremony set). */
enabledRootSlots: optional(t.array(RootKeyType)),
/** Bring-your-own backup key per multisig slot. Must match `externalBackupSlots` exactly. */
multisigRoots: optional(SafeMultisigRoots),
/** The safe's external backup slots as returned by initialize. Omit when every backup root is local. */
externalBackupSlots: optional(t.array(MultisigRootKeyType)),
} as const;

/**
Expand Down
72 changes: 72 additions & 0 deletions modules/express/test/unit/typedRoutes/generateSafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,48 @@ describe('Generate Safe Typed Routes Tests', function () {
generateSafeStub.firstCall.args[0].should.deepEqual({ label, passphrase });
});

it('should forward multisigRoots (bring-your-own backup key) to the SDK', async function () {
const label = 'Test Safe';
const passphrase = 'mySecurePassphrase123';
const multisigRoots = {
secp256k1Multisig: { backupXpub: 'xpub-backup' },
ed25519Multisig: { backupXpub: 'GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH' },
};
const generateSafeStub = sinon
.stub(Safes.prototype, 'generateSafe')
.resolves(stubSafe({ toJSON: () => mockSafeJson(label) }));

const res = await agent
.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`)
.send({ label, passphrase, multisigRoots });

res.status.should.equal(200);
generateSafeStub.should.have.been.calledOnce();
generateSafeStub.firstCall.args[0].should.deepEqual({ label, passphrase, multisigRoots });
});

it('should return 400 when a multisigRoots backupXpub is not a string', async function () {
const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({
label: 'Test Safe',
passphrase: 'password',
multisigRoots: { secp256k1Multisig: { backupXpub: 12345 } },
});

res.status.should.equal(400);
res.body.should.have.property('error');
});

it('should return 400 when multisigRoots omits backupXpub', async function () {
const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({
label: 'Test Safe',
passphrase: 'password',
multisigRoots: { ed25519Multisig: {} },
});

res.status.should.equal(400);
res.body.should.have.property('error');
});

it('should return 400 when label is missing', async function () {
const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/generate`).send({
passphrase: 'password',
Expand Down Expand Up @@ -246,6 +288,36 @@ describe('Generate Safe Typed Routes Tests', function () {
enabledRootSlots.should.deepEqual(['secp256k1Multisig']);
});

it('should forward multisigRoots and the declared externalBackupSlots to the SDK', async function () {
const multisigRoots = { secp256k1Multisig: { backupXpub: 'xpub-backup' } };
const createSafeKeysStub = sinon
.stub(Safes.prototype, 'createSafeKeys')
.resolves({ rootKeys: { hot: { secp256k1Multisig: ['user-1', 'backup-1', 'bitgo-1'] } } });

const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({
passphrase: 'password',
enabledRootSlots: ['secp256k1Multisig'],
multisigRoots,
externalBackupSlots: ['secp256k1Multisig'],
});

res.status.should.equal(200);
createSafeKeysStub.should.have.been.calledOnce();
createSafeKeysStub.firstCall.args[0].should.have.property('multisigRoots', multisigRoots);
createSafeKeysStub.firstCall.args[0].should.have.property('externalBackupSlots');
createSafeKeysStub.firstCall.args[0].externalBackupSlots?.should.deepEqual(['secp256k1Multisig']);
});

it('should return 400 when externalBackupSlots contains an MPC slot', async function () {
const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({
passphrase: 'password',
externalBackupSlots: ['ecdsaMpc'],
});

res.status.should.equal(400);
res.body.should.have.property('error');
});

it('should return 400 when passphrase is missing', async function () {
const res = await agent.post(`/api/v2/enterprise/${ENTERPRISE_ID}/safes/${SAFE_ID}/keys/generate`).send({});

Expand Down
22 changes: 18 additions & 4 deletions modules/key-card/src/generateQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,15 @@ function selectRootPrivateKey(keychain: Keychain, slot: SafeRootKeyType, role: '
// Box B carries the backup's ed25519 chain code alongside the encrypted seed for slot ④ only —
// the chain code is independent randomness and cannot be recomputed from the seed, unlike the
// slot-① xprv whose chain code rides inside the ciphertext. Every other slot (and boxes A/C
// everywhere) stays a bare string.
function selectBackupRootEntry(keychain: Keychain, slot: SafeRootKeyType): SafeKeycardRootEntry {
// everywhere) stays a bare string. A slot declared external carries the root public key instead.
function selectBackupRootEntry(keychain: Keychain, slot: SafeRootKeyType, external: boolean): SafeKeycardRootEntry {
if (external) {
// The caller (BYOK) holds the backup private key, so the card carries only the root public key.
// For slot ④ the pub is already the derivable composite, so the chain code survives.
assert.ok(keychain.pub, `Safe external backup root ${slot} is missing a public key`);
assert.ok(!keychain.encryptedPrv, `Safe external backup root ${slot} must not carry an encrypted private key`);
return { kind: 'external', pub: keychain.pub };
}
const prv = selectRootPrivateKey(keychain, slot, 'backup');
if (slot !== 'ed25519Multisig') {
return prv;
Expand Down Expand Up @@ -273,6 +280,7 @@ function buildSafeBoxData(
*/
export async function generateSafeQrData(params: GenerateSafeQrDataParams): Promise<QrData> {
const { roots } = params;
const externalBackupSlots = new Set<SafeRootKeyType>(params.externalBackupSlots ?? []);
const qrData: QrData = {
user: {
title: 'A: User Key',
Expand All @@ -281,8 +289,14 @@ export async function generateSafeQrData(params: GenerateSafeQrDataParams): Prom
},
backup: {
title: 'B: Backup Key',
description: 'Your 4 root backup keys, encrypted with your safe password.',
data: buildSafeBoxData(roots, (root, slot) => selectBackupRootEntry(root.backupKeychain, slot)),
description:
externalBackupSlots.size === 0
? 'Your 4 root backup keys, encrypted with your safe password.'
: 'Your 4 root backup keys, encrypted with your safe password. Slots marked external carry only the ' +
'public key of a backup key you supplied; its private key is not on this card and you must keep it safe.',
data: buildSafeBoxData(roots, (root, slot) =>
selectBackupRootEntry(root.backupKeychain, slot, externalBackupSlots.has(slot))
),
},
bitgo: {
title: 'C: BitGo Key',
Expand Down
4 changes: 4 additions & 0 deletions modules/key-card/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ 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 declared external backup slots (`SafeData.externalBackupSlots`). For these slots Box B
// carries the backup root public key instead of an encrypted private key. Omit for a safe whose
// backup roots are all local.
externalBackupSlots?: SafeRootKeyType[];
}

export type GenerateKeycardParams = GenerateQrDataBaseParams &
Expand Down
77 changes: 70 additions & 7 deletions modules/key-card/test/unit/safeQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,26 +127,89 @@ describe('generateSafeQrData', function () {
for (const slot of SAFE_ROOT_ORDER) {
(await decrypt(passphrase, userBox[slot] as string)).should.equal(plaintexts[slot].user);
const backupEntry = backupBox[slot];
const backupPrv = typeof backupEntry === 'string' ? backupEntry : backupEntry.prv;
const backupPrv = typeof backupEntry === 'string' ? backupEntry : (backupEntry as { prv: string }).prv;
(await decrypt(passphrase, backupPrv)).should.equal(plaintexts[slot].backup);
bitgoBox[slot].should.equal(bitgoPubs[slot]);
}

// Box B's slot-④ entry carries the chain code next to the seed (only slot ④ is an object).
const ed25519Backup = backupBox.ed25519Multisig;
const ed25519Backup = backupBox.ed25519Multisig as { prv: string; chainCode: string };
assert.ok(typeof ed25519Backup === 'object');
if (typeof ed25519Backup === 'object') {
ed25519Backup.chainCode.should.match(/^[A-Z2-7]{52}$/);
// The seed is byte-identical to the un-widened encrypted value (chain code NOT appended).
ed25519Backup.prv.should.equal(roots.ed25519Multisig.backupKeychain.encryptedPrv);
}
ed25519Backup.chainCode.should.match(/^[A-Z2-7]{52}$/);
// The seed is byte-identical to the un-widened encrypted value (chain code NOT appended).
ed25519Backup.prv.should.equal(roots.ed25519Multisig.backupKeychain.encryptedPrv);

// The other three box-B slots stay bare strings.
backupBox.secp256k1Multisig.should.be.a.String();
backupBox.ecdsaMpc.should.be.a.String();
backupBox.eddsaMpc.should.be.a.String();
});

describe('external backup slots', function () {
// A BYOK backup keychain is pub-only: the caller holds the private key.
function makeExternal(roots: Record<SafeRootKeyType, KeychainsTriplet>, slot: SafeRootKeyType, pub: string) {
roots[slot].backupKeychain = makeKeychain({ type: 'independent', pub });
}

it('emits an external entry for a declared slot instead of an encrypted backup prv', async function () {
const { roots, plaintexts } = await buildRoots();
makeExternal(roots, 'secp256k1Multisig', 'xpub-external-backup');
const qrData = await generateSafeQrData({
coin: coins.get('btc'),
roots,
externalBackupSlots: ['secp256k1Multisig'],
});
assert.ok(qrData.backup);

const backupBox = parseSafeKeycardBox(reassemble(qrData.backup.data));
backupBox.secp256k1Multisig.should.deepEqual({ kind: 'external', pub: 'xpub-external-backup' });
// The other slots stay local: encrypted prv, and the slot-④ chain-code object.
(await decrypt(passphrase, backupBox.ecdsaMpc as string)).should.equal(plaintexts.ecdsaMpc.backup);
(backupBox.ed25519Multisig as { chainCode: string }).chainCode.should.match(/^[A-Z2-7]{52}$/);
qrData.backup.description.should.match(/external/);
});

it('keeps the slot-④ derivable composite pub, chain code included', async function () {
const { roots } = await buildRoots();
const composite = roots.ed25519Multisig.backupKeychain.pub as string;
makeExternal(roots, 'ed25519Multisig', composite);
const qrData = await generateSafeQrData({
coin: coins.get('btc'),
roots,
externalBackupSlots: ['ed25519Multisig'],
});
assert.ok(qrData.backup);

parseSafeKeycardBox(qrData.backup.data).ed25519Multisig.should.deepEqual({ kind: 'external', pub: composite });
});

it('leaves boxes A and C unchanged and the backup description plain without external slots', async function () {
const { roots } = await buildRoots();
const qrData = await generateSafeQrData({ coin: coins.get('btc'), roots, externalBackupSlots: [] });
assert.ok(qrData.backup);
qrData.backup.description.should.not.match(/external/);
});

it('rejects a declared external slot whose backup keychain still carries an encrypted prv', async function () {
const { roots } = await buildRoots();
await generateSafeQrData({
coin: coins.get('btc'),
roots,
externalBackupSlots: ['secp256k1Multisig'],
}).should.be.rejectedWith(/must not carry an encrypted private key/);
});

it('rejects a declared external slot whose backup keychain has no pub', async function () {
const { roots } = await buildRoots();
roots.secp256k1Multisig.backupKeychain = makeKeychain({ type: 'independent', pub: undefined });
await generateSafeQrData({
coin: coins.get('btc'),
roots,
externalBackupSlots: ['secp256k1Multisig'],
}).should.be.rejectedWith(/missing a public key/);
});
});

it('preserves the versioned VRF envelope in the MPC root card blob', async function () {
const { roots } = await buildRoots();
const { reducedEnvelope } = buildSafeMpcKeyEnvelopes(
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
]
},
"dependencies": {
"@bitgo/public-types": "6.91.0",
"@bitgo/public-types": "7.1.0",
"@bitgo/sdk-lib-mpc": "^11.6.0",
"@bitgo/sdk-lib-safes": "^1.8.1",
"@bitgo/secp256k1": "^1.11.1",
Expand Down
6 changes: 6 additions & 0 deletions modules/sdk-core/src/bitgo/safe/iSafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// re-exporting from sdk-core.
import type {
FreezeSafeBody,
MultisigRootKeyType,
SafeData,
SafePermission,
SafeRootKeys,
Expand All @@ -23,6 +24,11 @@ import type { WebAuthnProvider } from '../webauthn';
/** @experimental */
export interface InitializeSafeOptions {
label: string;
/**
* Multisig slots whose backup root is external, i.e. not generated and encrypted by the SDK.
* Omit when every backup root is local.
*/
externalBackupSlots?: MultisigRootKeyType[];
}

/** @experimental */
Expand Down
Loading
Loading